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:
Dustin Byrne
2026-03-26 22:25:30 -04:00
parent 1c5eb494db
commit c802108a02
84 changed files with 792 additions and 1363 deletions

View File

@@ -1,14 +1,14 @@
---
name: hai-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.
name: kb-board
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
```bash
hai dashboard
kb dashboard
```
Opens the kanban board at http://localhost:4040 and starts the AI engine.
@@ -30,7 +30,7 @@ Run in two terminals:
```bash
# Terminal 1: start the server + engine
hai dashboard
kb dashboard
# Terminal 2: watch-rebuild the React dashboard UI
pnpm dev:ui
@@ -38,7 +38,7 @@ pnpm dev:ui
## Configuration
Settings are in `.hai/config.json`:
Settings are in `.kb/config.json`:
```json
{
@@ -61,13 +61,13 @@ Settings are in `.hai/config.json`:
## Task storage
Tasks live in `.hai/tasks/`:
Tasks live in `.kb/tasks/`:
```
.hai/
.kb/
├── config.json
└── tasks/
└── HAI-001/
└── KB-001/
├── task.json # Metadata, steps, log
└── PROMPT.md # Task specification
```

View File

@@ -1,11 +1,11 @@
---
name: hai-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.
name: kb-task
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**
## Commands
@@ -13,9 +13,9 @@ hai is an AI-orchestrated task board. Tasks flow through columns:
### Create a task
```bash
hai task create "description of what needs to be done"
hai task create "button is misaligned" --attach screenshot.png
hai task create "server crash" --attach error.log --attach trace.txt
kb task create "description of what needs to be done"
kb task create "button is misaligned" --attach screenshot.png
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
@@ -25,7 +25,7 @@ move it to **todo**.
Options:
- `--attach <file>` — attach files (images, logs, configs). Repeatable.
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:
- Be descriptive — the triage agent uses this to write the spec
@@ -37,7 +37,7 @@ Tips:
### List tasks
```bash
hai task list
kb task list
```
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
```bash
hai task show HAI-001
kb task show KB-001
```
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
```bash
hai task move HAI-001 <column>
kb task move KB-001 <column>
```
Columns: `triage`, `todo`, `in-progress`, `in-review`, `done`
@@ -68,7 +68,7 @@ Transitions are validated:
### Update step status
```bash
hai task update HAI-001 <step-number> <status>
kb task update KB-001 <step-number> <status>
```
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
```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.
@@ -86,7 +86,7 @@ Adds a timestamped log entry visible on the task card.
### Merge a completed task
```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.
@@ -95,12 +95,12 @@ Cleans up the worktree and branch after merge.
## 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
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
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
@@ -108,14 +108,14 @@ A task can be anything from a rough idea to a detailed spec:
```bash
# 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
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
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
hai task create "server crashes on startup" --attach crash.log
kb task create "server crashes on startup" --attach crash.log
```

View File

@@ -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.

View File

@@ -37,7 +37,7 @@ jobs:
run: pnpm test
- name: Build standalone binary
run: pnpm --filter hai build:exe
run: pnpm --filter @dustinbyrne/kb build:exe
- name: Verify binary exists
run: test -f packages/cli/dist/hai
run: test -f packages/cli/dist/kb

View File

@@ -29,16 +29,16 @@ jobs:
include:
- os: ubuntu-latest
target: bun-linux-x64
binary: hai-linux-x64
binary: kb-linux-x64
- os: macos-latest
target: bun-darwin-arm64
binary: hai-darwin-arm64
binary: kb-darwin-arm64
- os: macos-13
target: bun-darwin-x64
binary: hai-darwin-x64
binary: kb-darwin-x64
- os: windows-latest
target: bun-windows-x64
binary: hai-windows-x64.exe
binary: kb-windows-x64.exe
steps:
- name: Checkout
@@ -63,7 +63,7 @@ jobs:
run: pnpm build
- 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
shell: bash
@@ -132,7 +132,7 @@ jobs:
- name: Collect release files
run: |
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/
- name: Create GitHub Release

View File

@@ -19,16 +19,16 @@ jobs:
include:
- os: ubuntu-latest
target: bun-linux-x64
binary: hai-linux-x64
binary: kb-linux-x64
- os: macos-latest
target: bun-darwin-arm64
binary: hai-darwin-arm64
binary: kb-darwin-arm64
- os: macos-13
target: bun-darwin-x64
binary: hai-darwin-x64
binary: kb-darwin-x64
- os: windows-latest
target: bun-windows-x64
binary: hai-windows-x64.exe
binary: kb-windows-x64.exe
steps:
- name: Checkout
@@ -53,7 +53,7 @@ jobs:
run: pnpm build
- 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
shell: bash
@@ -132,7 +132,7 @@ jobs:
- name: Combine artifacts
run: |
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/
- name: Upload combined archive

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ dist/
*.tsbuildinfo
# hai runtime (local board state — don't commit)
.kb/
.hai/
# Taskplane runtime artifacts

View File

@@ -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).
@@ -39,16 +39,16 @@ pnpm dev task create "Fix the login redirect bug"
pnpm dev task list
# Move a task
pnpm dev task move HAI-001 todo
pnpm dev task move KB-001 todo
# Pause a task (stops all automation)
pnpm dev task pause HAI-001
pnpm dev task pause KB-001
# 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)
pnpm dev task attach HAI-001 ./screenshot.png
pnpm dev task attach KB-001 ./screenshot.png
# Create a task with attachments
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`
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
| Package | Description |
|---------|-------------|
| `@hai/core` | Domain model — tasks, board columns, file-based store |
| `@hai/dashboard` | Web UI — Express server + kanban board with SSE |
| `@hai/engine` | AI engine — triage (pi), execution (pi + worktrees), scheduling |
| `hai` (cli) | CLI — `hai dashboard`, `hai task create/list/move/attach` |
| `@kb/core` | Domain model — tasks, board columns, file-based store |
| `@kb/dashboard` | Web UI — Express server + kanban board with SSE |
| `@kb/engine` | AI engine — triage (pi), execution (pi + worktrees), scheduling |
| `kb` (cli) | CLI — `kb dashboard`, `kb task create/list/move/attach` |
## Architecture
### 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
└── tasks/
└── HAI-001/
└── KB-001/
├── task.json # Metadata (column, deps, timestamps)
├── PROMPT.md # Task specification
└── attachments/ # File attachments — images & text files (optional)
@@ -144,7 +144,7 @@ pnpm dev task list # CLI commands
## 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
pnpm build:exe
@@ -152,18 +152,18 @@ pnpm build:exe
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
Run the binary directly — no Node.js, pnpm, or workspace setup needed:
```bash
./packages/cli/dist/hai --help
./packages/cli/dist/hai task list
./packages/cli/dist/hai dashboard
./packages/cli/dist/kb --help
./packages/cli/dist/kb task list
./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
@@ -177,24 +177,24 @@ This produces binaries for all supported targets in `packages/cli/dist/`:
| Target | Output |
|--------|--------|
| `bun-linux-x64` | `hai-linux-x64` |
| `bun-linux-arm64` | `hai-linux-arm64` |
| `bun-darwin-x64` | `hai-darwin-x64` |
| `bun-darwin-arm64` | `hai-darwin-arm64` |
| `bun-windows-x64` | `hai-windows-x64.exe` |
| `bun-linux-x64` | `kb-linux-x64` |
| `bun-linux-arm64` | `kb-linux-arm64` |
| `bun-darwin-x64` | `kb-darwin-x64` |
| `bun-darwin-arm64` | `kb-darwin-arm64` |
| `bun-windows-x64` | `kb-windows-x64.exe` |
To build for a specific platform:
```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).
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
HAI_CLIENT_DIR=/path/to/client ./hai dashboard
KB_CLIENT_DIR=/path/to/client ./kb dashboard
```
**Prerequisites:** Bun ≥ 1.0 (`bun --version`)
@@ -206,7 +206,7 @@ Packages are published to npm automatically via GitHub Actions and [changesets](
### Installing from npm
```bash
npm install -g hai
npm install -g kb
```
### Triggering a release

View File

@@ -36,7 +36,7 @@ When you merge the Version Packages PR:
- The `version.yml` workflow detects that all changesets have been consumed
- 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:
- Builds platform-specific binaries for Linux x64, macOS x64, macOS arm64, and Windows x64
- Signs macOS binaries (codesign + notarization) and Windows binaries (Authenticode)
@@ -54,10 +54,10 @@ When you merge the Version Packages PR:
| Platform | Binary name | Signed |
|----------|------------|--------|
| Linux x64 | `hai-linux-x64` | — |
| macOS arm64 | `hai-darwin-arm64` | ✓ (codesign + notarization) |
| macOS x64 | `hai-darwin-x64` | ✓ (codesign + notarization) |
| Windows x64 | `hai-windows-x64.exe` | ✓ (Authenticode) |
| Linux x64 | `kb-linux-x64` | — |
| macOS arm64 | `kb-darwin-arm64` | ✓ (codesign + notarization) |
| macOS x64 | `kb-darwin-x64` | ✓ (codesign + notarization) |
| Windows x64 | `kb-windows-x64.exe` | ✓ (Authenticode) |
## 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 status` | Check pending changesets |
| `pnpm release:version` | Apply changesets and bump versions (used by CI) |
| `pnpm --filter hai build:exe` | Build binary for current platform |
| `pnpm --filter hai 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` | Build binary for current platform |
| `pnpm --filter @dustinbyrne/kb build:exe -- --target <target>` | Cross-compile for a specific platform |
| `pnpm --filter @dustinbyrne/kb build:exe:all` | Build binaries for all platforms |
## Tips

View File

@@ -1,6 +1,6 @@
# 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
@@ -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/)
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
### 5. Determine Your Signing Identity

572
package-lock.json generated
View File

@@ -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"
}
}
}
}

View File

@@ -1,15 +1,15 @@
{
"name": "hai-workspace",
"name": "kb-workspace",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.0",
"scripts": {
"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:exe": "pnpm build && pnpm --filter hai build:exe",
"build:exe:all": "pnpm build && pnpm --filter hai build:exe:all",
"build:exe": "pnpm build && pnpm --filter @dustinbyrne/kb build:exe",
"build:exe:all": "pnpm build && pnpm --filter @dustinbyrne/kb build:exe:all",
"test": "pnpm -r test",
"typecheck": "pnpm -r typecheck",
"changeset": "changeset",

View File

@@ -1,8 +1,8 @@
#!/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/.
*
* Usage:
@@ -38,20 +38,20 @@ type BunTarget = (typeof SUPPORTED_TARGETS)[number];
/**
* 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 {
// "bun-linux-x64" → "linux-x64"
const suffix = target.replace(/^bun-/, "");
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).
*/
function defaultBinaryName(): string {
return process.platform === "win32" ? "hai.exe" : "hai";
return process.platform === "win32" ? "kb.exe" : "kb";
}
// ── Parse CLI arguments ───────────────────────────────────────────────
@@ -146,7 +146,7 @@ const { targets } = parseArgs();
copyClientAssets();
if (targets === null) {
// Default: build for current platform → dist/hai
// Default: build for current platform → dist/kb
const outBinary = join(outDir, defaultBinaryName());
const ok = compileBinary(outBinary, "bun");
if (!ok) process.exit(1);

View File

@@ -1,9 +1,9 @@
{
"name": "@dustinbyrne/hai",
"name": "@dustinbyrne/kb",
"version": "0.1.0",
"type": "module",
"bin": {
"hai": "./dist/bin.js"
"kb": "./dist/bin.js"
},
"files": [
"dist",
@@ -18,9 +18,9 @@
"test": "vitest run"
},
"dependencies": {
"@hai/core": "workspace:*",
"@hai/dashboard": "workspace:*",
"@hai/engine": "workspace:*",
"@kb/core": "workspace:*",
"@kb/dashboard": "workspace:*",
"@kb/engine": "workspace:*",
"@mariozechner/pi-coding-agent": "^0.62.0"
},
"devDependencies": {

View File

@@ -21,7 +21,7 @@ const SUPPORTED_TARGETS = [
function expectedBinaryName(target: string): string {
const suffix = target.replace(/^bun-/, "");
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);
it("produces dist/hai-linux-x64", () => {
const bin = join(distDir, "hai-linux-x64");
it("produces dist/kb-linux-x64", () => {
const bin = join(distDir, "kb-linux-x64");
expect(existsSync(bin)).toBe(true);
expect(statSync(bin).size).toBeGreaterThan(0);
});
@@ -64,8 +64,8 @@ describe("build-exe-cross: windows target has .exe extension", () => {
});
}, 180_000);
it("produces dist/hai-windows-x64.exe", () => {
const bin = join(distDir, "hai-windows-x64.exe");
it("produces dist/kb-windows-x64.exe", () => {
const bin = join(distDir, "kb-windows-x64.exe");
expect(existsSync(bin)).toBe(true);
expect(statSync(bin).size).toBeGreaterThan(0);
});
@@ -108,7 +108,7 @@ describe("build-exe-cross: --all builds all platforms", () => {
timeout: 15_000,
});
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);
it("produces dist/hai (no platform suffix)", () => {
const defaultName = process.platform === "win32" ? "hai.exe" : "hai";
it("produces dist/kb (no platform suffix)", () => {
const defaultName = process.platform === "win32" ? "kb.exe" : "kb";
const bin = join(distDir, defaultName);
expect(existsSync(bin)).toBe(true);
expect(statSync(bin).size).toBeGreaterThan(0);

View File

@@ -6,8 +6,8 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
const cliRoot = join(import.meta.dirname!, "..", "..");
const outBinary = join(cliRoot, "dist", process.platform === "win32" ? "hai.exe" : "hai");
const binaryName = process.platform === "win32" ? "hai.exe" : "hai";
const outBinary = join(cliRoot, "dist", process.platform === "win32" ? "kb.exe" : "kb");
const binaryName = process.platform === "win32" ? "kb.exe" : "kb";
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.
*/
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(clientDir, join(dir, "client"), { recursive: true });
return {
@@ -60,7 +60,7 @@ describe("build-exe", () => {
timeout: 15_000,
});
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("task create");
expect(result.stdout).toContain("task list");
@@ -110,7 +110,7 @@ describe("build-exe", () => {
resolve(out);
});
});
expect(output).toContain("hai board");
expect(output).toContain("kb board");
} finally {
cleanup();
}

View File

@@ -51,7 +51,7 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
});
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", () => {

View File

@@ -19,9 +19,9 @@ function loadWorkflowYaml(name: string): any {
describe("CLI package.json publishing config", () => {
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.hai).toBe("./dist/bin.js");
expect(pkg.bin.kb).toBe("./dist/bin.js");
});
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"];
for (const name of scopedPackages) {
describe(`@hai/${name}`, () => {
describe(`@kb/${name}`, () => {
const pkg = loadPackageJson(name);
it('has publishConfig with access "public"', () => {

View File

@@ -24,11 +24,11 @@ if (isBunBinary) {
if (!existsSync(localPkg)) {
// 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(
join(tmp, "package.json"),
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,
2,
) + "\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 HELP = `
hai — AI-orchestrated task board
kb — AI-orchestrated task board
Usage:
hai dashboard Start the board web UI
hai task create [desc] [--attach f] Create a new task (goes to triage)
hai task list List all tasks
hai task show <id> Show task details, steps, log
hai task move <id> <col> Move a task to a column
hai task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
hai task log <id> <message> Add a log entry
hai task merge <id> Merge an in-review task and close it
hai task attach <id> <file> Attach a file to a task
hai task pause <id> Pause a task (stops all automation)
hai task unpause <id> Unpause a task (resumes automation)
kb dashboard Start the board web UI
kb task create [desc] [--attach f] Create a new task (goes to triage)
kb task list List all tasks
kb task show <id> Show task details, steps, log
kb task move <id> <col> Move a task to a column
kb task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
kb task log <id> <message> Add a log entry
kb task merge <id> Merge an in-review task and close it
kb task attach <id> <file> Attach a file to a task
kb task pause <id> Pause a task (stops all automation)
kb task unpause <id> Unpause a task (resumes automation)
Options:
--port, -p <port> Dashboard port (default: 4040)
@@ -118,7 +118,7 @@ async function main() {
const id = args[2];
const column = args[3];
if (!id || !column) {
console.error("Usage: hai task move <id> <column>");
console.error("Usage: kb task move <id> <column>");
process.exit(1);
}
await runTaskMove(id, column);
@@ -126,14 +126,14 @@ async function main() {
}
case "show": {
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);
break;
}
case "update": {
const id = args[2], step = args[3], status = args[4];
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");
process.exit(1);
}
@@ -142,20 +142,20 @@ async function main() {
}
case "log": {
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);
break;
}
case "merge": {
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);
break;
}
case "attach": {
const id = args[2], file = args[3];
if (!id || !file) {
console.error("Usage: hai task attach <id> <file>");
console.error("Usage: kb task attach <id> <file>");
process.exit(1);
}
await runTaskAttach(id, file);
@@ -163,19 +163,19 @@ async function main() {
}
case "pause": {
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);
break;
}
case "unpause": {
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);
break;
}
default:
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);
}
break;

View File

@@ -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()),
}));
// ── Mock @hai/dashboard ─────────────────────────────────────────────
// ── Mock @kb/dashboard ─────────────────────────────────────────────
const mockListen = vi.fn();
vi.mock("@hai/dashboard", () => ({
vi.mock("@kb/dashboard", () => ({
createServer: vi.fn(() => ({ listen: mockListen })),
}));
// ── Mock @hai/engine ────────────────────────────────────────────────
// ── Mock @kb/engine ────────────────────────────────────────────────
vi.mock("@hai/engine", async (importOriginal) => {
const original = await importOriginal<typeof import("@hai/engine")>();
vi.mock("@kb/engine", async (importOriginal) => {
const original = await importOriginal<typeof import("@kb/engine")>();
return {
...original,
WorktreePool: original.WorktreePool,
@@ -81,12 +81,12 @@ const { runDashboard } = await import("../dashboard.js");
describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { TaskStore } = await import("@hai/core");
const { TaskStore } = await import("@kb/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
});
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 });

View File

@@ -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()),
}));
// ── Mock @hai/dashboard ─────────────────────────────────────────────
// ── Mock @kb/dashboard ─────────────────────────────────────────────
const mockListen = vi.fn();
vi.mock("@hai/dashboard", () => ({
vi.mock("@kb/dashboard", () => ({
createServer: vi.fn(() => ({ listen: mockListen })),
}));
// ── Mock @hai/engine ────────────────────────────────────────────────
// ── Mock @kb/engine ────────────────────────────────────────────────
// 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) => {
const original = await importOriginal<typeof import("@hai/engine")>();
vi.mock("@kb/engine", async (importOriginal) => {
const original = await importOriginal<typeof import("@kb/engine")>();
return {
...original,
// Keep real WorktreePool & AgentSemaphore
@@ -81,10 +81,10 @@ describe("runDashboard — WorktreePool wiring", () => {
capturedExecutorOpts = undefined;
vi.clearAllMocks();
// 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());
// Re-set engine mocks
const engine = await import("@hai/engine");
const engine = await import("@kb/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
);
@@ -104,8 +104,8 @@ describe("runDashboard — WorktreePool wiring", () => {
});
it("passes a WorktreePool instance to aiMergeTask via rawMerge", async () => {
const { aiMergeTask } = await import("@hai/engine");
const { createServer } = await import("@hai/dashboard");
const { aiMergeTask } = await import("@kb/engine");
const { createServer } = await import("@kb/dashboard");
await runDashboard(0, { open: false });
@@ -114,7 +114,7 @@ describe("runDashboard — WorktreePool wiring", () => {
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
// Invoke the merge handler
await serverOpts.onMerge("HAI-TEST");
await serverOpts.onMerge("KB-TEST");
expect(aiMergeTask).toHaveBeenCalled();
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 () => {
const { aiMergeTask } = await import("@hai/engine");
const { createServer } = await import("@hai/dashboard");
const { aiMergeTask } = await import("@kb/engine");
const { createServer } = await import("@kb/dashboard");
await runDashboard(0, { open: false });
// Trigger merger via onMerge
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
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 mergerPool = (aiMergeTask as ReturnType<typeof vi.fn>).mock.calls[0][3].pool;
@@ -148,9 +148,9 @@ describe("runDashboard — auto-merge pause exclusion", () => {
capturedExecutorOpts = undefined;
vi.clearAllMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@hai/core");
const { TaskStore } = await import("@kb/core");
(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(() =>
Promise.resolve({ merged: true }),
);
@@ -172,11 +172,11 @@ describe("runDashboard — auto-merge pause exclusion", () => {
await runDashboard(0, { open: false });
const { aiMergeTask } = await import("@hai/engine");
const { aiMergeTask } = await import("@kb/engine");
// Emit task:moved with a paused task
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",
to: "in-review",
});
@@ -195,11 +195,11 @@ describe("runDashboard — auto-merge pause exclusion", () => {
pollIntervalMs: 60_000,
});
mockStore.listTasks.mockResolvedValue([
{ id: "HAI-PAUSED", column: "in-review", paused: true },
{ id: "HAI-ACTIVE", column: "in-review", paused: false },
{ id: "KB-PAUSED", column: "in-review", paused: true },
{ id: "KB-ACTIVE", column: "in-review", paused: false },
]);
const { aiMergeTask } = await import("@hai/engine");
const { aiMergeTask } = await import("@kb/engine");
// Reset after import
(aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
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(
(call: any[]) => call[2],
);
expect(mergedIds).not.toContain("HAI-PAUSED");
expect(mergedIds).not.toContain("KB-PAUSED");
});
});

View File

@@ -1,7 +1,7 @@
import { exec } from "node:child_process";
import { TaskStore } from "@hai/core";
import { createServer } from "@hai/dashboard";
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask } from "@hai/engine";
import { TaskStore } from "@kb/core";
import { createServer } from "@kb/dashboard";
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask } from "@kb/engine";
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
function openBrowser(url: string): void {
@@ -228,11 +228,11 @@ export async function runDashboard(port: number, opts: { open?: boolean } = {})
app.listen(port, () => {
console.log();
console.log(` hai board`);
console.log(` kb board`);
console.log(` ────────────────────────`);
console.log(` → http://localhost:${port}`);
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(` AI engine: ✓ active`);
console.log(` • triage: auto-specifying tasks`);

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock @hai/core before importing the module under test
vi.mock("@hai/core", () => {
// Mock @kb/core before importing the module under test
vi.mock("@kb/core", () => {
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
const COLUMN_LABELS: Record<string, string> = {
triage: "Triage",
@@ -18,15 +18,15 @@ vi.mock("@hai/core", () => {
};
});
// Mock @hai/engine
vi.mock("@hai/engine", () => ({ aiMergeTask: vi.fn() }));
// Mock @kb/engine
vi.mock("@kb/engine", () => ({ aiMergeTask: vi.fn() }));
import { TaskStore } from "@hai/core";
import { TaskStore } from "@kb/core";
import { runTaskShow, runTaskCreate } from "./task.js";
function makeTask(overrides: Record<string, unknown> = {}) {
return {
id: "HAI-001",
id: "KB-001",
description: "A short description",
column: "triage",
dependencies: [],
@@ -59,16 +59,16 @@ describe("runTaskShow", () => {
getTask: vi.fn().mockResolvedValue(task),
}));
await runTaskShow("HAI-001");
await runTaskShow("KB-001");
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![0]).toContain(longDesc);
// Ensure no truncation happened
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 () => {
@@ -82,10 +82,10 @@ describe("runTaskShow", () => {
getTask: vi.fn().mockResolvedValue(task),
}));
await runTaskShow("HAI-001");
await runTaskShow("KB-001");
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![0]).toContain("My Task Title");
@@ -119,7 +119,7 @@ describe("runTaskCreate with --attach", () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: vi.fn().mockResolvedValue({
id: "HAI-002",
id: "KB-002",
description: "test task",
column: "triage",
dependencies: [],
@@ -146,7 +146,7 @@ describe("runTaskCreate with --attach", () => {
expect(mockAddAttachment).toHaveBeenCalledOnce();
expect(mockAddAttachment).toHaveBeenCalledWith(
"HAI-002",
"KB-002",
"test.png",
expect.any(Buffer),
"image/png",

View File

@@ -1,5 +1,5 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult, type StepStatus } from "@hai/core";
import { aiMergeTask } from "@hai/engine";
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult, type StepStatus } from "@kb/core";
import { aiMergeTask } from "@kb/engine";
import { createInterface } from "node:readline/promises";
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(` ✓ Created ${task.id}: ${label}`);
console.log(` Column: triage`);
console.log(` Path: .hai/tasks/${task.id}/`);
console.log(` Path: .kb/tasks/${task.id}/`);
if (attachFiles && attachFiles.length > 0) {
const { readFile } = await import("node:fs/promises");
@@ -73,7 +73,7 @@ export async function runTaskList() {
const tasks = await store.listTasks();
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;
}
@@ -246,7 +246,7 @@ export async function runTaskAttach(id: string, filePath: string) {
console.log();
console.log(` ✓ Attached to ${id}: ${attachment.originalName}`);
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();
}

View File

@@ -1,5 +1,5 @@
{
"name": "@hai/core",
"name": "@kb/core",
"version": "0.1.0",
"type": "module",
"exports": {

View File

@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
import type { Task } from "./types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "hai-store-test-"));
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
}
describe("TaskStore", () => {
@@ -32,7 +32,7 @@ describe("TaskStore", () => {
async function createTaskWithSteps(): Promise<Task> {
const task = await store.createTask({ description: "Task with steps" });
// 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(
join(dir, "PROMPT.md"),
`# ${task.id}: Task with steps
@@ -63,7 +63,7 @@ describe("TaskStore", () => {
const detail = await store.getTask(task.id);
// 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
const count = detail.prompt.split("Fix the login bug").length - 1;
expect(count).toBe(1);
@@ -76,7 +76,7 @@ describe("TaskStore", () => {
});
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");
});
@@ -88,7 +88,7 @@ describe("TaskStore", () => {
const detail = await store.getTask(task.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)
const count = detail.prompt.split("Implement caching layer").length - 1;
expect(count).toBe(1);
@@ -102,7 +102,7 @@ describe("TaskStore", () => {
});
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");
});
});
@@ -129,7 +129,7 @@ describe("TaskStore", () => {
await Promise.all(promises);
// 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 result = JSON.parse(raw) as Task;
@@ -144,7 +144,7 @@ describe("TaskStore", () => {
describe("defensive JSON parsing", () => {
it("throws on corrupted task.json with trailing duplicate content (atomic writes prevent this)", async () => {
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
const validJson = await readFile(taskJsonPath, "utf-8");
@@ -157,7 +157,7 @@ describe("TaskStore", () => {
it("throws a clear error when JSON is completely unrecoverable", async () => {
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
await writeFile(taskJsonPath, "not json at all {{{");
@@ -171,7 +171,7 @@ describe("TaskStore", () => {
describe("atomic writes", () => {
it("produces valid JSON after write with no .tmp files left behind", async () => {
const task = await createTestTask();
const dir = join(rootDir, ".hai", "tasks", task.id);
const dir = join(rootDir, ".kb", "tasks", task.id);
// Perform a write
await store.logEntry(task.id, "atomic test");
@@ -200,18 +200,18 @@ describe("TaskStore", () => {
const ids = tasks.map((t) => t.id);
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();
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
const configPath = join(rootDir, ".hai", "config.json");
const configPath = join(rootDir, ".kb", "config.json");
const raw = await readFile(configPath, "utf-8");
const config = JSON.parse(raw);
expect(config.nextId).toBe(6);
// No .tmp files left behind
const haiDir = join(rootDir, ".hai");
const haiDir = join(rootDir, ".kb");
const files = await readdir(haiDir);
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
});
@@ -240,7 +240,7 @@ describe("TaskStore", () => {
expect(updated.attachments![0].filename).toBe(attachment.filename);
// 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);
expect(content).toEqual(TINY_PNG);
});
@@ -296,7 +296,7 @@ describe("TaskStore", () => {
expect(updated.attachments).toBeUndefined();
// 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);
});
@@ -369,48 +369,48 @@ describe("TaskStore", () => {
const task = await createTestTask();
expect(task.dependencies).toEqual([]);
const updated = await store.updateTask(task.id, { dependencies: ["HAI-001", "HAI-002"] });
expect(updated.dependencies).toEqual(["HAI-001", "HAI-002"]);
const updated = await store.updateTask(task.id, { dependencies: ["KB-001", "KB-002"] });
expect(updated.dependencies).toEqual(["KB-001", "KB-002"]);
// Verify persistence
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 () => {
const task = await store.createTask({ description: "Dep task", dependencies: ["HAI-001"] });
expect(task.dependencies).toEqual(["HAI-001"]);
const task = await store.createTask({ description: "Dep task", dependencies: ["KB-001"] });
expect(task.dependencies).toEqual(["KB-001"]);
const updated = await store.updateTask(task.id, { dependencies: ["HAI-002", "HAI-003"] });
expect(updated.dependencies).toEqual(["HAI-002", "HAI-003"]);
const updated = await store.updateTask(task.id, { dependencies: ["KB-002", "KB-003"] });
expect(updated.dependencies).toEqual(["KB-002", "KB-003"]);
});
it("clears dependencies with empty array", async () => {
const task = await store.createTask({ description: "Dep task", dependencies: ["HAI-001"] });
expect(task.dependencies).toEqual(["HAI-001"]);
const task = await store.createTask({ description: "Dep task", dependencies: ["KB-001"] });
expect(task.dependencies).toEqual(["KB-001"]);
const updated = await store.updateTask(task.id, { dependencies: [] });
expect(updated.dependencies).toEqual([]);
});
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" });
expect(updated.dependencies).toEqual(["HAI-001"]);
expect(updated.dependencies).toEqual(["KB-001"]);
});
});
describe("updateTask — blockedBy", () => {
it("sets blockedBy to a string value", async () => {
const task = await store.createTask({ title: "Blocked task", description: "A task" });
const updated = await store.updateTask(task.id, { blockedBy: "HAI-999" });
expect(updated.blockedBy).toBe("HAI-999");
const updated = await store.updateTask(task.id, { blockedBy: "KB-999" });
expect(updated.blockedBy).toBe("KB-999");
});
it("clears blockedBy when set to null", async () => {
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 });
expect(updated.blockedBy).toBeUndefined();
});
@@ -419,9 +419,9 @@ describe("TaskStore", () => {
// ── Task prefix tests ──────────────────────────────────────────
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" });
expect(task.id).toBe("HAI-001");
expect(task.id).toBe("KB-001");
});
it("custom prefix produces PROJ-001 IDs", async () => {
@@ -433,8 +433,8 @@ describe("TaskStore", () => {
it("prefix change mid-stream continues sequence", async () => {
const t1 = await store.createTask({ description: "First" });
const t2 = await store.createTask({ description: "Second" });
expect(t1.id).toBe("HAI-001");
expect(t2.id).toBe("HAI-002");
expect(t1.id).toBe("KB-001");
expect(t2.id).toBe("KB-002");
await store.updateSettings({ taskPrefix: "PROJ" });
const t3 = await store.createTask({ description: "Third" });
@@ -448,7 +448,7 @@ describe("TaskStore", () => {
const tasks = await store.listTasks();
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"]);
});
});

View File

@@ -16,7 +16,7 @@ export interface TaskStoreEvents {
}
export class TaskStore extends EventEmitter<TaskStoreEvents> {
private haiDir: string;
private kbDir: string;
private tasksDir: string;
private configPath: string;
@@ -37,9 +37,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
constructor(private rootDir: string) {
super();
this.haiDir = join(rootDir, ".hai");
this.tasksDir = join(this.haiDir, "tasks");
this.configPath = join(this.haiDir, "config.json");
this.kbDir = join(rootDir, ".kb");
this.tasksDir = join(this.kbDir, "tasks");
this.configPath = join(this.kbDir, "config.json");
}
async init(): Promise<void> {
@@ -160,7 +160,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private async allocateId(): Promise<string> {
return this.withConfigLock(async () => {
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")}`;
config.nextId++;
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 result: MergeResult = {
task,
@@ -556,7 +556,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
`Merge conflict merging '${branch}'. Resolve manually:\n` +
` cd ${this.rootDir}\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).
* 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.
*
* @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 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)
@@ -903,7 +903,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* Read all historical agent log entries for a task from its agent log file.
* 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
*/
async getAgentLogs(taskId: string): Promise<AgentLogEntry[]> {

View File

@@ -94,12 +94,12 @@ export interface Settings {
* of being deleted. New tasks acquire a warm worktree from the pool,
* preserving build caches (node_modules, target/, dist/). Default: false. */
recycleWorktrees?: boolean;
/** Prefix for generated task IDs (e.g. `"HAI"` produces `HAI-001`).
* Defaults to `"HAI"`. Only affects new tasks — existing tasks retain
/** Prefix for generated task IDs (e.g. `"KB"` produces `KB-001`).
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
* their original IDs. */
taskPrefix?: string;
/** 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. */
includeTaskIdInCommit?: boolean;
/** Default AI model provider name (e.g. `"anthropic"`, `"openai"`).

View File

@@ -1,5 +1,5 @@
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 { Header } from "./components/Header";
import { Board } from "./components/Board";

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
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 = {
id: "HAI-001",
id: "KB-001",
description: "Test",
column: "in-progress",
dependencies: [],
@@ -12,7 +12,7 @@ const FAKE_DETAIL: TaskDetail = {
log: [],
createdAt: "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) {
@@ -38,9 +38,9 @@ describe("fetchTaskDetail", () => {
it("returns data on first success", async () => {
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);
});
@@ -49,9 +49,9 @@ describe("fetchTaskDetail", () => {
.mockReturnValueOnce(mockFetchResponse(false, { error: "Transient error" }))
.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);
});
@@ -59,7 +59,7 @@ describe("fetchTaskDetail", () => {
globalThis.fetch = vi.fn()
.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
});
});
@@ -72,10 +72,10 @@ describe("updateTask", () => {
});
const FAKE_TASK: Task = {
id: "HAI-001",
id: "KB-001",
description: "Test",
column: "in-progress",
dependencies: ["HAI-002"],
dependencies: ["KB-002"],
steps: [],
currentStep: 0,
log: [],
@@ -86,20 +86,20 @@ describe("updateTask", () => {
it("sends PATCH with dependencies and returns updated task", async () => {
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(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/HAI-001", {
expect(result.dependencies).toEqual(["KB-002"]);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ dependencies: ["HAI-002"] }),
body: JSON.stringify({ dependencies: ["KB-002"] }),
});
});
it("throws on error response", async () => {
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");
});
});

View File

@@ -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> {
const res = await fetch(`/api${path}`, {

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { AgentLogEntry } from "@hai/core";
import type { AgentLogEntry } from "@kb/core";
interface AgentLogViewerProps {
entries: AgentLogEntry[];

View File

@@ -1,5 +1,5 @@
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@hai/core";
import { COLUMNS } from "@hai/core";
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@kb/core";
import { COLUMNS } from "@kb/core";
import { Column } from "./Column";
import type { ToastType } from "../hooks/useToast";

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@hai/core";
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@hai/core";
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@kb/core";
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@kb/core";
import { TaskCard } from "./TaskCard";
import { WorktreeGroup } from "./WorktreeGroup";
import { InlineCreateCard } from "./InlineCreateCard";

View File

@@ -8,8 +8,8 @@ export function Header({ onOpenSettings }: HeaderProps) {
return (
<header className="header">
<div className="header-left">
<img src="/logo.svg" alt="hai logo" className="header-logo" width={24} height={24} />
<h1 className="logo">hai</h1>
<img src="/logo.svg" alt="kb logo" className="header-logo" width={24} height={24} />
<h1 className="logo">kb</h1>
<span className="logo-sub">board</span>
</div>
<div className="header-actions">

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect, useRef } from "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 { uploadAttachment } from "../api";

View File

@@ -1,5 +1,5 @@
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 type { AuthProvider, ModelInfo } from "../api";
import type { ToastType } from "../hooks/useToast";
@@ -185,7 +185,7 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
<input
id="taskPrefix"
type="text"
placeholder="HAI"
placeholder="KB"
value={form.taskPrefix || ""}
onChange={(e) => {
const val = e.target.value;
@@ -198,7 +198,7 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
}}
/>
{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>
</>
);
@@ -412,7 +412,7 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
/>
Include task ID in commit scope
</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>
</>
);

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
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
vi.mock("lucide-react", () => ({
@@ -19,7 +19,7 @@ import { uploadAttachment } from "../api";
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "HAI-001",
id: "KB-001",
title: "Test task",
column: "in-progress",
status: undefined as any,
@@ -35,7 +35,7 @@ const noop = () => {};
describe("TaskCard", () => {
it("renders the card ID text", () => {
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", () => {
@@ -126,7 +126,7 @@ describe("TaskCard", () => {
});
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith("HAI-001", file);
expect(mockUpload).toHaveBeenCalledWith("KB-001", file);
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Attached test.png"),
"success",

View File

@@ -1,6 +1,6 @@
import { useCallback, useState } from "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 type { ToastType } from "../hooks/useToast";

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@hai/core";
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@kb/core";
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@kb/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";

View File

@@ -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 { TaskCard } from "./TaskCard";
import type { ToastType } from "../hooks/useToast";

View File

@@ -1,12 +1,12 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { AgentLogViewer } from "../AgentLogViewer";
import type { AgentLogEntry } from "@hai/core";
import type { AgentLogEntry } from "@kb/core";
function makeEntry(overrides: Partial<AgentLogEntry> = {}): AgentLogEntry {
return {
timestamp: "2026-01-01T00:00:00Z",
taskId: "HAI-001",
taskId: "KB-001",
text: "Hello world",
type: "text",
...overrides,

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
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
vi.mock("../Column", () => ({

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
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
vi.mock("../TaskCard", () => ({
@@ -43,7 +43,7 @@ const defaultProps = {
describe("Column count-flash", () => {
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} />);
const badge = screen.getByText("1");
@@ -52,10 +52,10 @@ describe("Column count-flash", () => {
});
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 moreTasks = [makeTask("HAI-001"), makeTask("HAI-002")];
const moreTasks = [makeTask("KB-001"), makeTask("KB-002")];
rerender(<Column {...defaultProps} tasks={moreTasks} />);
const badge = screen.getByText("2");
@@ -63,10 +63,10 @@ describe("Column count-flash", () => {
});
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 fewerTasks = [makeTask("HAI-001")];
const fewerTasks = [makeTask("KB-001")];
rerender(<Column {...defaultProps} tasks={fewerTasks} />);
const badge = screen.getByText("1");

View File

@@ -5,7 +5,7 @@ import { Header } from "../Header";
describe("Header", () => {
it("renders a logo image with correct src and alt", () => {
render(<Header />);
const logo = screen.getByAltText("hai logo");
const logo = screen.getByAltText("kb logo");
expect(logo).toBeDefined();
expect(logo.tagName).toBe("IMG");
expect((logo as HTMLImageElement).src).toContain("/logo.svg");
@@ -13,7 +13,7 @@ describe("Header", () => {
it("renders the logo before the h1 element", () => {
render(<Header />);
const logo = screen.getByAltText("hai logo");
const logo = screen.getByAltText("kb logo");
const h1 = screen.getByRole("heading", { level: 1 });
// Logo should be a preceding sibling of the h1
expect(logo.compareDocumentPosition(h1) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();

View File

@@ -15,7 +15,7 @@ vi.mock("../../api", () => ({
function renderCard() {
const props = {
tasks: [],
onSubmit: vi.fn().mockResolvedValue({ id: "HAI-001" }),
onSubmit: vi.fn().mockResolvedValue({ id: "KB-001" }),
onCancel: vi.fn(),
addToast: vi.fn(),
};

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SettingsModal } from "../SettingsModal";
import type { Settings } from "@hai/core";
import type { Settings } from "@kb/core";
const defaultSettings: Settings = {
maxConcurrent: 2,

View File

@@ -1,5 +1,5 @@
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.
@@ -139,11 +139,11 @@ describe("TaskCard dependency tooltip", () => {
}
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", () => {
expect(computeDepTooltip(["HAI-010"])).toBe("HAI-010");
expect(computeDepTooltip(["KB-010"])).toBe("KB-010");
});
it("returns undefined when dependencies array is empty", () => {
@@ -151,12 +151,12 @@ describe("TaskCard dependency tooltip", () => {
});
it("handles many dependencies", () => {
const deps = ["HAI-001", "HAI-002", "HAI-003", "HAI-004"];
expect(computeDepTooltip(deps)).toBe("HAI-001, HAI-002, HAI-003, HAI-004");
const deps = ["KB-001", "KB-002", "KB-003", "KB-004"];
expect(computeDepTooltip(deps)).toBe("KB-001, KB-002, KB-003, KB-004");
});
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);
expect(tooltip).toBeDefined();
// 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", () => {
expect(shouldShowScopeBadge("HAI-003")).toBe(true);
expect(shouldShowScopeBadge("KB-003")).toBe(true);
});
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", () => {
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", () => {
@@ -200,7 +200,7 @@ describe("TaskCard file-scope overlap badge logic", () => {
}
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)");
});
});

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
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", () => ({
uploadAttachment: vi.fn(),
@@ -16,7 +16,7 @@ vi.mock("../../hooks/useAgentLogs", () => ({
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "HAI-099",
id: "KB-099",
description: "Test task",
column: "in-progress" as Column,
dependencies: [],
@@ -157,7 +157,7 @@ describe("TaskDetailModal", () => {
task={makeTask({
title: undefined,
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}
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");
expect(markdownBody?.innerHTML).not.toContain("HAI-099");
expect(markdownBody?.innerHTML).not.toContain("KB-099");
// Description appears in the markdown body
expect(markdownBody?.textContent).toContain("Fix the login bug");
// 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
const h2 = container.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("Fix the login bug");
@@ -210,7 +210,7 @@ describe("TaskDetailModal", () => {
addToast={noop}
/>,
);
expect(withTitle.querySelector(".detail-id")?.textContent).toBe("HAI-099");
expect(withTitle.querySelector(".detail-id")?.textContent).toBe("KB-099");
// Without title
const { container: withoutTitle } = render(
@@ -223,7 +223,7 @@ describe("TaskDetailModal", () => {
addToast={noop}
/>,
);
expect(withoutTitle.querySelector(".detail-id")?.textContent).toBe("HAI-099");
expect(withoutTitle.querySelector(".detail-id")?.textContent).toBe("KB-099");
});
describe("paste image upload", () => {
@@ -267,7 +267,7 @@ describe("TaskDetailModal", () => {
});
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith("HAI-099", imageFile);
expect(mockUpload).toHaveBeenCalledWith("KB-099", imageFile);
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
});
});
@@ -394,7 +394,7 @@ describe("TaskDetailModal", () => {
});
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith("HAI-099", imageFile);
expect(mockUpload).toHaveBeenCalledWith("KB-099", imageFile);
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
});
});
@@ -418,7 +418,7 @@ describe("TaskDetailModal", () => {
it("renders dependency list when dependencies exist", () => {
render(
<TaskDetailModal
task={makeTask({ dependencies: ["HAI-001", "HAI-002"] })}
task={makeTask({ dependencies: ["KB-001", "KB-002"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
@@ -427,16 +427,16 @@ describe("TaskDetailModal", () => {
/>,
);
expect(screen.getByText("HAI-001")).toBeTruthy();
expect(screen.getByText("HAI-002")).toBeTruthy();
expect(screen.getByText("KB-001")).toBeTruthy();
expect(screen.getByText("KB-002")).toBeTruthy();
expect(screen.queryByText("(no dependencies)")).toBeNull();
});
it("can add a dependency via the dropdown", async () => {
const { updateTask } = await import("../../api");
const allTasks: Task[] = [
{ id: "HAI-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-001", description: "Dep 1", column: "todo" 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(
@@ -452,16 +452,16 @@ describe("TaskDetailModal", () => {
);
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")!;
expect(dropdown).toBeTruthy();
expect(dropdown.textContent).toContain("HAI-001");
expect(dropdown.textContent).toContain("KB-001");
expect(dropdown.querySelectorAll(".dep-dropdown-item")).toHaveLength(1);
fireEvent.click(screen.getByText("HAI-001"));
fireEvent.click(screen.getByText("KB-001"));
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(
<TaskDetailModal
task={makeTask({ dependencies: ["HAI-001", "HAI-002"] })}
task={makeTask({ dependencies: ["KB-001", "KB-002"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
@@ -480,10 +480,10 @@ describe("TaskDetailModal", () => {
);
const removeButtons = screen.getAllByTitle(/Remove dependency/);
fireEvent.click(removeButtons[0]); // Remove HAI-001
fireEvent.click(removeButtons[0]); // Remove KB-001
await waitFor(() => {
expect(updateTask).toHaveBeenCalledWith("HAI-099", { dependencies: ["HAI-002"] });
expect(updateTask).toHaveBeenCalledWith("KB-099", { dependencies: ["KB-002"] });
});
});

View File

@@ -51,7 +51,7 @@ afterEach(() => {
describe("useAgentLogs", () => {
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(MockEventSource.instances).toHaveLength(0);
@@ -60,27 +60,27 @@ describe("useAgentLogs", () => {
it("fetches historical logs and opens SSE when enabled=true", async () => {
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);
const { result } = renderHook(() => useAgentLogs("HAI-001", true));
const { result } = renderHook(() => useAgentLogs("KB-001", true));
await waitFor(() => {
expect(result.current.entries).toEqual(historicalLogs);
});
expect(mockFetchAgentLogs).toHaveBeenCalledWith("HAI-001");
expect(mockFetchAgentLogs).toHaveBeenCalledWith("KB-001");
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 () => {
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(() => {
expect(result.current.entries).toHaveLength(1);
@@ -90,7 +90,7 @@ describe("useAgentLogs", () => {
act(() => {
es._emit("agent:log", {
timestamp: "2026-01-01T00:01:00Z",
taskId: "HAI-001",
taskId: "KB-001",
text: "new",
type: "text",
});
@@ -104,7 +104,7 @@ describe("useAgentLogs", () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
const { rerender } = renderHook(
({ enabled }) => useAgentLogs("HAI-001", enabled),
({ enabled }) => useAgentLogs("KB-001", enabled),
{ initialProps: { enabled: true } },
);
@@ -122,7 +122,7 @@ describe("useAgentLogs", () => {
it("closes SSE on unmount", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
const { unmount } = renderHook(() => useAgentLogs("HAI-001", true));
const { unmount } = renderHook(() => useAgentLogs("KB-001", true));
await waitFor(() => {
expect(MockEventSource.instances).toHaveLength(1);

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { AgentLogEntry } from "@hai/core";
import type { AgentLogEntry } from "@kb/core";
import { fetchAgentLogs } from "../api";
/**

View File

@@ -1,5 +1,5 @@
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";
export function useTasks() {

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<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" />
</head>
<body>

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
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 {
return {
@@ -18,8 +18,8 @@ function makeTask(overrides: Partial<Task> & { id: string }): Task {
describe("getWorktreeLabel", () => {
it("extracts last path segment", () => {
expect(getWorktreeLabel(".worktrees/HAI-001")).toBe("HAI-001");
expect(getWorktreeLabel("/path/to/hai/hai-001")).toBe("hai-001");
expect(getWorktreeLabel(".worktrees/KB-001")).toBe("KB-001");
expect(getWorktreeLabel("/path/to/kb/kb-001")).toBe("kb-001");
});
it("extracts humanized worktree names", () => {
@@ -31,8 +31,8 @@ describe("getWorktreeLabel", () => {
describe("groupByWorktree", () => {
it("groups active in-progress tasks by worktree", () => {
const t1 = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
const t2 = makeTask({ id: "HAI-002", worktree: ".worktrees/quiet-robin" });
const t1 = makeTask({ id: "KB-001", worktree: ".worktrees/swift-falcon" });
const t2 = makeTask({ id: "KB-002", worktree: ".worktrees/quiet-robin" });
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", () => {
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
const active = makeTask({ id: "KB-001", worktree: ".worktrees/swift-falcon" });
const queued = makeTask({
id: "HAI-002",
id: "KB-002",
column: "todo",
dependencies: [],
});
@@ -66,7 +66,7 @@ describe("groupByWorktree", () => {
});
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);
@@ -74,11 +74,11 @@ describe("groupByWorktree", () => {
});
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({
id: "HAI-002",
id: "KB-002",
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);
@@ -87,10 +87,10 @@ describe("groupByWorktree", () => {
});
it("respects maxConcurrent limit on queued tasks shown", () => {
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
const q1 = makeTask({ id: "HAI-010", column: "todo" });
const q2 = makeTask({ id: "HAI-011", column: "todo" });
const q3 = makeTask({ id: "HAI-012", column: "todo" });
const active = makeTask({ id: "KB-001", worktree: ".worktrees/swift-falcon" });
const q1 = makeTask({ id: "KB-010", column: "todo" });
const q2 = makeTask({ id: "KB-011", column: "todo" });
const q3 = makeTask({ id: "KB-012", column: "todo" });
const groups = groupByWorktree([active], [active, q1, q2, q3], 2);
@@ -100,7 +100,7 @@ describe("groupByWorktree", () => {
});
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);
@@ -110,15 +110,15 @@ describe("groupByWorktree", () => {
});
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({
id: "HAI-002",
id: "KB-002",
column: "todo",
dependencies: [],
paused: true,
});
const normal = makeTask({
id: "HAI-003",
id: "KB-003",
column: "todo",
dependencies: [],
});
@@ -127,16 +127,16 @@ describe("groupByWorktree", () => {
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks.map((t) => t.id)).toEqual(["HAI-003"]);
expect(upNext!.queuedTasks.map((t) => t.id)).not.toContain("HAI-002");
expect(upNext!.queuedTasks.map((t) => t.id)).toEqual(["KB-003"]);
expect(upNext!.queuedTasks.map((t) => t.id)).not.toContain("KB-002");
});
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({
id: "HAI-002",
id: "KB-002",
column: "todo",
dependencies: ["HAI-001"],
dependencies: ["KB-001"],
});
const groups = groupByWorktree([], [done, queued], 2);

View File

@@ -1,4 +1,4 @@
import type { Task } from "@hai/core";
import type { Task } from "@kb/core";
export interface WorktreeGroupData {
label: string;
@@ -8,7 +8,7 @@ export interface WorktreeGroupData {
/**
* 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 {
// Take the last segment of the path
@@ -18,8 +18,8 @@ export function getWorktreeLabel(worktreePath: string): string {
/**
* Topological sort of tasks by dependency order.
* Mirrors resolveDependencyOrder from @hai/core but inlined to avoid
* build alias issues (Vite aliases @hai/core to types.ts only).
* Mirrors resolveDependencyOrder from @kb/core but inlined to avoid
* build alias issues (Vite aliases @kb/core to types.ts only).
*/
function resolveDependencyOrder(tasks: Task[]): string[] {
const taskMap = new Map(tasks.map((t) => [t.id, t]));

View File

@@ -1,5 +1,5 @@
{
"name": "@hai/dashboard",
"name": "@kb/dashboard",
"version": "0.1.0",
"type": "module",
"exports": {
@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {
"@hai/core": "workspace:*",
"@kb/core": "workspace:*",
"@types/multer": "^2.1.0",
"express": "^5.1.0",
"lucide-react": "^1.7.0",

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8" />
<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" />
</head>
<body>
<header class="header">
<div class="header-left">
<h1 class="logo">hai</h1>
<h1 class="logo">kb</h1>
<span class="logo-sub">board</span>
</div>
<button class="btn btn-primary" id="add-task-btn">+ New Task</button>
@@ -96,7 +96,7 @@
>Dependencies
<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 class="modal-actions">
<button type="button" class="btn" data-close="create-modal">

View File

@@ -2,8 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import http from "node:http";
import { createApiRoutes } from "./routes.js";
import type { TaskStore, TaskAttachment } from "@hai/core";
import type { TaskDetail } from "@hai/core";
import type { TaskStore, TaskAttachment } from "@kb/core";
import type { TaskDetail } from "@kb/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
@@ -24,7 +24,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
}
const FAKE_TASK_DETAIL: TaskDetail = {
id: "HAI-001",
id: "KB-001",
description: "Test task",
column: "in-progress",
dependencies: [],
@@ -33,7 +33,7 @@ const FAKE_TASK_DETAIL: TaskDetail = {
log: [],
createdAt: "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 } */
@@ -117,11 +117,11 @@ describe("GET /tasks/:id", () => {
it("returns task detail on success", async () => {
(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.body.id).toBe("HAI-001");
expect(res.body.prompt).toBe("# HAI-001\n\nTest task");
expect(res.body.id).toBe("KB-001");
expect(res.body.prompt).toBe("# KB-001\n\nTest task");
});
it("returns 404 when task genuinely does not exist (ENOENT)", async () => {
@@ -129,7 +129,7 @@ describe("GET /tasks/:id", () => {
err.code = "ENOENT";
(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.body.error).toContain("not found");
@@ -139,7 +139,7 @@ describe("GET /tasks/:id", () => {
const err = new Error("Unexpected end of JSON input");
(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.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.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",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", { status: undefined });
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "todo");
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
});
it("returns 400 when task is not in failed state", async () => {
const activeTask = { ...FAKE_TASK_DETAIL, status: "executing" };
(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",
});
@@ -192,7 +192,7 @@ describe("POST /tasks/:id/retry", () => {
const doneTask = { ...FAKE_TASK_DETAIL, column: "done", status: "failed" };
(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",
});
@@ -216,32 +216,32 @@ describe("PATCH /tasks/:id", () => {
}
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);
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",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: 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 () => {
(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",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", {
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: "New",
description: undefined,
prompt: undefined,
@@ -280,14 +280,14 @@ describe("Attachment routes", () => {
const content = Buffer.from("fake 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}`,
});
expect(res.status).toBe(201);
expect(res.body.filename).toBe("1234-screenshot.png");
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"HAI-001",
"KB-001",
"screenshot.png",
expect.any(Buffer),
"image/png",
@@ -302,7 +302,7 @@ describe("Attachment routes", () => {
const content = Buffer.from("not an image");
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}`,
});
@@ -318,7 +318,7 @@ describe("Attachment routes", () => {
const content = Buffer.from("small but store rejects");
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}`,
});
@@ -327,10 +327,10 @@ describe("Attachment routes", () => {
});
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((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 () => {
@@ -338,29 +338,29 @@ describe("Attachment routes", () => {
err.code = "ENOENT";
(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);
});
it("GET /tasks/:id/logs — returns agent logs", async () => {
const fakeLogs = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "HAI-001", text: "Hello", type: "text" },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "HAI-001", text: "Read", type: "tool" },
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "Hello", type: "text" },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "KB-001", text: "Read", type: "tool" },
];
(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.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 () => {
(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.body).toEqual([]);
@@ -369,7 +369,7 @@ describe("Attachment routes", () => {
it("GET /tasks/:id/logs — returns 500 on store error", async () => {
(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.body.error).toBe("disk error");
@@ -631,27 +631,27 @@ describe("Pause/Unpause endpoints", () => {
beforeEach(() => {
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 () => {
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.body).toEqual({ id: "HAI-001", paused: true });
expect(store.pauseTask).toHaveBeenCalledWith("HAI-001", true);
expect(res.body).toEqual({ id: "KB-001", paused: true });
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", true);
});
it("POST /tasks/:id/unpause — unpauses a task", async () => {
(store.pauseTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "HAI-001" });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/unpause");
(store.pauseTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "KB-001" });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unpause");
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 () => {
(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.body.error).toBe("not found");
});

View File

@@ -1,8 +1,8 @@
import { Router } from "express";
import multer from "multer";
import { createReadStream } from "node:fs";
import type { TaskStore, Column, MergeResult } from "@hai/core";
import { COLUMNS } from "@hai/core";
import type { TaskStore, Column, MergeResult } from "@kb/core";
import { COLUMNS } from "@kb/core";
import type { ServerOptions } from "./server.js";
/**

View File

@@ -2,7 +2,7 @@ import express from "express";
import { join, dirname } from "node:path";
import { existsSync } from "node:fs";
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 { createApiRoutes } from "./routes.js";
import { createSSE } from "./sse.js";
@@ -27,14 +27,14 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// Serve built React app
// Resolution order:
// 1. HAI_CLIENT_DIR env override (explicit)
// 2. Next to process.execPath (bun-compiled binary: dist/hai + dist/client/)
// 1. KB_CLIENT_DIR env override (explicit)
// 2. Next to process.execPath (bun-compiled binary: dist/kb + dist/client/)
// 3. __dirname/../dist/client (running from src/ via tsx/ts-node)
// 4. __dirname/../client (running from dist/ after tsc)
// 5. __dirname/../public (fallback for dev)
const execDir = dirname(process.execPath);
const clientDir = process.env.HAI_CLIENT_DIR
? process.env.HAI_CLIENT_DIR
const clientDir = process.env.KB_CLIENT_DIR
? process.env.KB_CLIENT_DIR
: existsSync(join(execDir, "client", "index.html"))
? join(execDir, "client")
: existsSync(join(__dirname, "..", "dist", "client"))

View File

@@ -1,5 +1,5 @@
import type { Request, Response } from "express";
import type { TaskStore } from "@hai/core";
import type { TaskStore } from "@kb/core";
export function createSSE(store: TaskStore) {
return (_req: Request, res: Response) => {

View File

@@ -7,7 +7,7 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@hai/core": resolve(__dirname, "../core/src/types.ts"),
"@kb/core": resolve(__dirname, "../core/src/types.ts"),
},
},
build: {

View File

@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@hai/core": resolve(__dirname, "../core/src/types.ts"),
"@kb/core": resolve(__dirname, "../core/src/types.ts"),
},
},
test: {

View File

@@ -1,5 +1,5 @@
{
"name": "@hai/engine",
"name": "@kb/engine",
"version": "0.1.0",
"type": "module",
"exports": {
@@ -21,7 +21,7 @@
"test": "vitest run"
},
"dependencies": {
"@hai/core": "workspace:*",
"@kb/core": "workspace:*",
"@mariozechner/pi-ai": "^0.62.0",
"@mariozechner/pi-coding-agent": "^0.62.0"
},

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { AgentLogger, summarizeToolArgs } from "./agent-logger.js";
import type { TaskStore } from "@hai/core";
import type { TaskStore } from "@kb/core";
// ── summarizeToolArgs tests ──────────────────────────────────────────
@@ -62,7 +62,7 @@ describe("AgentLogger", () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "HAI-001",
taskId: "KB-001",
flushSizeBytes: 10,
flushIntervalMs: 500,
});
@@ -75,14 +75,14 @@ describe("AgentLogger", () => {
logger.onText("worldextra");
// Allow async flush
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 () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "HAI-002",
taskId: "KB-002",
flushSizeBytes: 1024,
flushIntervalMs: 500,
});
@@ -91,14 +91,14 @@ describe("AgentLogger", () => {
expect(store.appendAgentLog).not.toHaveBeenCalled();
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 () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "HAI-003",
taskId: "KB-003",
flushSizeBytes: 1024,
});
@@ -110,36 +110,36 @@ describe("AgentLogger", () => {
const calls = (store.appendAgentLog as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.length).toBe(2);
// 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
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 () => {
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" });
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 () => {
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 });
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 () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "HAI-006",
taskId: "KB-006",
flushSizeBytes: 1024,
flushIntervalMs: 500,
});
@@ -147,12 +147,12 @@ describe("AgentLogger", () => {
logger.onText("remaining");
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 () => {
const store = createMockStore();
const logger = new AgentLogger({ store, taskId: "HAI-007" });
const logger = new AgentLogger({ store, taskId: "KB-007" });
await logger.flush();
expect(store.appendAgentLog).not.toHaveBeenCalled();
@@ -164,23 +164,23 @@ describe("AgentLogger", () => {
const onAgentTool = vi.fn();
const logger = new AgentLogger({
store,
taskId: "HAI-008",
taskId: "KB-008",
onAgentText,
onAgentTool,
});
logger.onText("delta");
expect(onAgentText).toHaveBeenCalledWith("HAI-008", "delta");
expect(onAgentText).toHaveBeenCalledWith("KB-008", "delta");
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 () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "HAI-009",
taskId: "KB-009",
flushSizeBytes: 1024,
flushIntervalMs: 500,
});
@@ -193,6 +193,6 @@ describe("AgentLogger", () => {
// All text should be flushed in a single call
expect(store.appendAgentLog).toHaveBeenCalledTimes(1);
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-009", "abc", "text");
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-009", "abc", "text");
});
});

View File

@@ -1,4 +1,4 @@
import type { TaskStore } from "@hai/core";
import type { TaskStore } from "@kb/core";
/** Default byte threshold before an automatic flush. */
const FLUSH_SIZE_BYTES = 1024;
@@ -56,12 +56,12 @@ export interface AgentLoggerOptions {
* detailed argument summaries via {@link summarizeToolArgs}.
*
* Produces `onText` and `onToolStart` callbacks compatible with
* `createHaiAgent`'s `AgentOptions` interface.
* `createKbAgent`'s `AgentOptions` interface.
*
* @example
* ```ts
* const logger = new AgentLogger({ store, taskId, onAgentText, onAgentTool });
* const { session } = await createHaiAgent({
* const { session } = await createKbAgent({
* cwd: worktreePath,
* onText: logger.onText,
* onToolStart: logger.onToolStart,

View File

@@ -3,7 +3,7 @@ import { AgentSemaphore } from "./concurrency.js";
// Mock external dependencies
vi.mock("./pi.js", () => ({
createHaiAgent: vi.fn(),
createKbAgent: vi.fn(),
}));
vi.mock("./reviewer.js", () => ({
reviewStep: vi.fn(),
@@ -28,14 +28,14 @@ vi.mock("node:fs", () => ({
}));
import { TaskExecutor, buildExecutionPrompt } from "./executor.js";
import { createHaiAgent } from "./pi.js";
import { createKbAgent } from "./pi.js";
import { execSync } from "node:child_process";
import { findWorktreeUser, aiMergeTask } from "./merger.js";
import { WorktreePool } from "./worktree-pool.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() {
const listeners = new Map<string, Function[]>();
@@ -52,7 +52,7 @@ function createMockStore() {
emit: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn().mockResolvedValue({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test task",
column: "in-progress",
@@ -102,7 +102,7 @@ describe("TaskExecutor with semaphore", () => {
const executor = new TaskExecutor(store, "/tmp/test", { semaphore: sem });
await executor.execute({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "in-progress",
@@ -132,7 +132,7 @@ describe("TaskExecutor with semaphore", () => {
});
await executor.execute({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "in-progress",
@@ -157,7 +157,7 @@ describe("TaskExecutor with semaphore", () => {
const executor = new TaskExecutor(store, "/tmp/test", { onError });
await executor.execute({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "in-progress",
@@ -169,7 +169,7 @@ describe("TaskExecutor with semaphore", () => {
updatedAt: new Date().toISOString(),
});
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", { status: "failed" });
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed" });
expect(onError).toHaveBeenCalled();
});
@@ -209,9 +209,9 @@ describe("TaskExecutor with semaphore", () => {
});
await Promise.all([
executor.execute(task("HAI-001")),
executor.execute(task("HAI-002")),
executor.execute(task("HAI-003")),
executor.execute(task("KB-001")),
executor.execute(task("KB-002")),
executor.execute(task("KB-003")),
]);
expect(maxConcurrent).toBe(1);
@@ -224,7 +224,7 @@ const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
describe("TaskExecutor worktreeInitCommand", () => {
const makeTask = (id = "HAI-010") => ({
const makeTask = (id = "KB-010") => ({
id,
title: "Test",
description: "Test",
@@ -275,7 +275,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
// Should log success
expect(store.logEntry).toHaveBeenCalledWith(
"HAI-010",
"KB-010",
"Worktree init command completed",
"pnpm install",
);
@@ -322,7 +322,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
// Should log the failure
expect(store.logEntry).toHaveBeenCalledWith(
"HAI-010",
"KB-010",
expect.stringContaining("Worktree init command failed"),
);
@@ -356,7 +356,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
});
describe("TaskExecutor worktree naming", () => {
const makeTask = (id = "HAI-030", worktree?: string) => ({
const makeTask = (id = "KB-030", worktree?: string) => ({
id,
title: "Test",
description: "Test",
@@ -391,7 +391,7 @@ describe("TaskExecutor worktree naming", () => {
await executor.execute(makeTask());
// 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",
});
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
@@ -401,7 +401,7 @@ describe("TaskExecutor worktree naming", () => {
const store = createMockStore();
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
const updateCalls = store.updateTask.mock.calls;
@@ -409,7 +409,7 @@ describe("TaskExecutor worktree naming", () => {
(call: any[]) => call[1]?.worktree !== undefined,
);
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");
});
@@ -420,7 +420,7 @@ describe("TaskExecutor worktree naming", () => {
const store = createMockStore();
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
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
@@ -428,7 +428,7 @@ describe("TaskExecutor worktree naming", () => {
});
describe("TaskExecutor worktree pool integration", () => {
const makeTask = (id = "HAI-020") => ({
const makeTask = (id = "KB-020") => ({
id,
title: "Test",
description: "Test",
@@ -482,7 +482,7 @@ describe("TaskExecutor worktree pool integration", () => {
// Should log pool acquisition
expect(store.logEntry).toHaveBeenCalledWith(
"HAI-020",
"KB-020",
expect.stringContaining("Acquired worktree from pool"),
);
@@ -515,7 +515,7 @@ describe("TaskExecutor worktree pool integration", () => {
// Should log worktree creation, NOT pool acquisition
expect(store.logEntry).toHaveBeenCalledWith(
"HAI-020",
"KB-020",
expect.stringContaining("Worktree created at"),
);
});
@@ -597,12 +597,12 @@ describe("Merger worktree pool integration", () => {
}),
emit: vi.fn(),
getTask: vi.fn().mockResolvedValue({
id: "HAI-050",
id: "KB-050",
title: "Test merge",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/test/.worktrees/HAI-050",
worktree: "/tmp/test/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
@@ -612,7 +612,7 @@ describe("Merger worktree pool integration", () => {
}),
updateTask: vi.fn().mockResolvedValue({}),
moveTask: vi.fn().mockResolvedValue({
id: "HAI-050",
id: "KB-050",
column: "done",
dependencies: [],
steps: [],
@@ -659,10 +659,10 @@ describe("Merger worktree pool integration", () => {
},
} 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
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);
// git worktree remove should NOT have been called
@@ -686,7 +686,7 @@ describe("Merger worktree pool integration", () => {
},
} 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
expect(pool.size).toBe(0);
@@ -702,7 +702,7 @@ describe("Merger worktree pool integration", () => {
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "HAI-001",
id: "KB-001",
title: "Test Task",
description: "A test task",
column: "in-progress",
@@ -728,7 +728,7 @@ describe("buildExecutionPrompt", () => {
expect(result).toContain("## Attachments");
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", () => {
@@ -742,7 +742,7 @@ describe("buildExecutionPrompt", () => {
expect(result).toContain("## Attachments");
expect(result).toContain("**error.log** (text/plain)");
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", () => {
@@ -853,7 +853,7 @@ describe("buildExecutionPrompt", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "in-progress",
@@ -929,7 +929,7 @@ describe("TaskExecutor pause behavior", () => {
session: {
prompt: vi.fn().mockImplementation(async () => {
// 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)
throw new Error("Session terminated");
}),
@@ -940,7 +940,7 @@ describe("TaskExecutor pause behavior", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "in-progress",
@@ -953,8 +953,8 @@ describe("TaskExecutor pause behavior", () => {
});
// Should move to todo, NOT mark as failed
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("HAI-001", { status: "failed" });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("KB-001", { status: "failed" });
});
it("does not move to in-review when paused during execution (graceful session end)", async () => {
@@ -965,7 +965,7 @@ describe("TaskExecutor pause behavior", () => {
session: {
prompt: vi.fn().mockImplementation(async () => {
// 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(),
},
@@ -974,7 +974,7 @@ describe("TaskExecutor pause behavior", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "in-progress",
@@ -987,14 +987,14 @@ describe("TaskExecutor pause behavior", () => {
});
// 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 () => {
const store = createMockStore();
store.listTasks.mockResolvedValue([
{ id: "HAI-001", column: "in-progress", paused: true, title: "Paused task" },
{ id: "HAI-002", column: "in-progress", paused: false, title: "Active task" },
{ id: "KB-001", column: "in-progress", paused: true, title: "Paused task" },
{ id: "KB-002", column: "in-progress", paused: false, title: "Active task" },
]);
mockedCreateHaiAgent.mockResolvedValue({
@@ -1007,8 +1007,8 @@ describe("TaskExecutor pause behavior", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.resumeOrphaned();
// Only HAI-002 should be resumed (HAI-001 is paused)
expect(store.logEntry).toHaveBeenCalledWith("HAI-002", "Resumed after engine restart");
expect(store.logEntry).not.toHaveBeenCalledWith("HAI-001", expect.anything());
// Only KB-002 should be resumed (KB-001 is paused)
expect(store.logEntry).toHaveBeenCalledWith("KB-002", "Resumed after engine restart");
expect(store.logEntry).not.toHaveBeenCalledWith("KB-001", expect.anything());
});
});

View File

@@ -1,11 +1,11 @@
import { execSync } from "node:child_process";
import { join } from "node:path";
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 { generateWorktreeName } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createHaiAgent } from "./pi.js";
import { createKbAgent } from "./pi.js";
import { reviewStep } from "./reviewer.js";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import type { AgentSemaphore } from "./concurrency.js";
@@ -36,7 +36,7 @@ const taskLogParams = Type.Object({
const taskCreateParams = Type.Object({
description: Type.String({ description: "What needs to be done" }),
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.
@@ -239,7 +239,7 @@ export class TaskExecutor {
* than being named after the task ID. This decouples directory names from
* tasks, enabling worktree reuse across dependency chains. When resuming
* 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> {
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
const branchName = `hai/${task.id.toLowerCase()}`;
const branchName = `kb/${task.id.toLowerCase()}`;
// Use generateWorktreeName for human-friendly directory names (adjective-noun pattern)
// instead of task.id, so worktrees are named like ".worktrees/swift-falcon"
let worktreePath = task.worktree || join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
@@ -343,7 +343,7 @@ export class TaskExecutor {
});
const agentWork = async () => {
const { session } = await createHaiAgent({
const { session } = await createKbAgent({
cwd: worktreePath,
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
tools: "coding",
@@ -664,7 +664,7 @@ git log --oneline
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
const lines = ["## 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)) {
lines.push(`- **${att.originalName}** (screenshot): \`${absPath}\``);
} else {

View File

@@ -5,6 +5,6 @@ export { TaskExecutor, type TaskExecutorOptions } from "./executor.js";
export { Scheduler, type SchedulerOptions } from "./scheduler.js";
export { aiMergeTask, type MergerOptions } from "./merger.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 { createLogger, type Logger } from "./logger.js";

View File

@@ -1,5 +1,5 @@
/**
* Lightweight structured logger for the `@hai/engine` package.
* Lightweight structured logger for the `@kb/engine` package.
*
* Usage:
* ```ts

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock external dependencies
vi.mock("./pi.js", () => ({
createHaiAgent: vi.fn(),
createKbAgent: vi.fn(),
}));
vi.mock("node:child_process", () => ({
@@ -14,23 +14,23 @@ vi.mock("node:fs", () => ({
}));
import { aiMergeTask, findWorktreeUser } from "./merger.js";
import { createHaiAgent } from "./pi.js";
import { createKbAgent } from "./pi.js";
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 { existsSync: mockedExistsSyncRaw } = await import("node:fs");
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
const baseTask: Task = {
id: "HAI-050",
id: "KB-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/HAI-050",
worktree: "/tmp/root/.worktrees/KB-050",
steps: [],
currentStep: 0,
log: [],
@@ -73,27 +73,27 @@ function setupHappyPathExecSync() {
describe("findWorktreeUser", () => {
it("returns null when no other task uses the worktree", async () => {
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();
});
it("returns task ID when another non-done task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "HAI-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
{ id: "KB-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "KB-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
expect(result).toBe("HAI-051");
const result = await findWorktreeUser(store, "/tmp/wt", "KB-050");
expect(result).toBe("KB-051");
});
it("ignores done tasks", async () => {
const store = createMockStore({}, [
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "HAI-051", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "KB-050", 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();
});
});
@@ -112,16 +112,16 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
});
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(
{ id: "HAI-050", worktree: worktreePath },
{ id: "KB-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "HAI-051", worktree: worktreePath, column: "in-progress" } as Task,
{ id: "KB-050", worktree: worktreePath, column: "in-review" } 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
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 () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const worktreePath = "/tmp/root/.worktrees/KB-050";
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(
(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 () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
{ id: "KB-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "HAI-051", worktree: worktreePath, column: "in-progress" } as Task,
{ id: "KB-050", worktree: worktreePath, column: "in-review" } 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
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 () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
{ id: "KB-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "HAI-051", worktree: worktreePath, column: "todo" } as Task,
{ id: "KB-050", worktree: worktreePath, column: "in-review" } 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.merged).toBe(true);
});
@@ -200,11 +200,11 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
it("includes task ID in system prompt by default (includeTaskIdInCommit: true)", async () => {
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ 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;
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 () => {
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
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;
expect(agentCall.systemPrompt).toContain("<type>: <summary>");
@@ -245,17 +245,17 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
});
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ 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(
(call) => String(call[0]).includes("git commit"),
);
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 () => {
@@ -273,22 +273,22 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
});
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
includeTaskIdInCommit: false,
});
await aiMergeTask(store, "/tmp/root", "HAI-050");
await aiMergeTask(store, "/tmp/root", "KB-050");
const commitCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("git commit"),
);
expect(commitCall).toBeDefined();
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);
});
it("passes defaultProvider and defaultModelId from settings to createHaiAgent", async () => {
it("passes defaultProvider and defaultModelId from settings to createKbAgent", async () => {
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
@@ -316,7 +316,7 @@ describe("aiMergeTask — model settings threading", () => {
defaultModelId: "gpt-4o",
});
await aiMergeTask(store, "/tmp/root", "HAI-050");
await aiMergeTask(store, "/tmp/root", "KB-050");
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
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 () => {
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ 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;
expect(opts.defaultProvider).toBeUndefined();
@@ -361,15 +361,15 @@ describe("aiMergeTask — agent log persistence", () => {
} as any;
});
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
{ id: "KB-050", worktree: worktreePath },
[{ 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 () => {
@@ -387,15 +387,15 @@ describe("aiMergeTask — agent log persistence", () => {
} as any;
});
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
{ id: "KB-050", worktree: worktreePath },
[{ 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 () => {
@@ -414,15 +414,15 @@ describe("aiMergeTask — agent log persistence", () => {
} as any;
});
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const worktreePath = "/tmp/root/.worktrees/KB-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
{ id: "KB-050", worktree: worktreePath },
[{ 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(store.appendAgentLog).toHaveBeenCalledWith("HAI-050", "hi", "text");
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-050", "hi", "text");
});
});

View File

@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import type { TaskStore, Task, MergeResult } from "@hai/core";
import { createHaiAgent } from "./pi.js";
import type { TaskStore, Task, MergeResult } from "@kb/core";
import { createKbAgent } from "./pi.js";
import type { WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
import { mergerLog } from "./logger.js";
@@ -19,13 +19,13 @@ git commit -m "<type>(<scope>): <summary>" -m "<body>"
Message format:
- **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)
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
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
- Add profile image resizing via sharp
- 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"
\`\`\``;
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.
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 result: MergeResult = {
task,
@@ -231,7 +231,7 @@ export async function aiMergeTask(
});
// Forward model settings from store so the merger honours the user's model choice
const { session } = await createHaiAgent({
const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt: buildMergeSystemPrompt(includeTaskId),
tools: "coding",

View File

@@ -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).
* 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.
*/
export async function createHaiAgent(options: AgentOptions): Promise<AgentResult> {
export async function createKbAgent(options: AgentOptions): Promise<AgentResult> {
const authStorage = AuthStorage.create();
const modelRegistry = new ModelRegistry(authStorage);

View File

@@ -15,7 +15,7 @@ import { AgentSemaphore } from "./concurrency.js";
// ── Module-level mocks (matching existing test patterns) ──────────────────
vi.mock("./pi.js", () => ({
createHaiAgent: vi.fn(),
createKbAgent: vi.fn(),
}));
vi.mock("./reviewer.js", () => ({
reviewStep: vi.fn(),
@@ -31,12 +31,12 @@ import { TaskExecutor } from "./executor.js";
import { TriageProcessor } from "./triage.js";
import { Scheduler } from "./scheduler.js";
import { aiMergeTask } from "./merger.js";
import { createHaiAgent } from "./pi.js";
import { createKbAgent } from "./pi.js";
import { execSync } from "node:child_process";
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 mockedExistsSync = vi.mocked(existsSync);
@@ -61,7 +61,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
}),
emit: vi.fn(),
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({}),
moveTask: vi.fn().mockImplementation(async (id: string, col: Column) => {
return makeTask(id, col);
@@ -74,7 +74,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
return makeTaskDetail(id, "in-progress");
}),
createTask: vi.fn().mockImplementation(async (input: any) => {
return makeTask("HAI-NEW", "triage");
return makeTask("KB-NEW", "triage");
}),
deleteTask: vi.fn().mockResolvedValue(undefined),
_listeners: listeners,
@@ -139,9 +139,9 @@ beforeEach(() => {
describe("In-progress task resume after restart", () => {
it("resumeOrphaned() calls execute() for each in-progress task not already executing", async () => {
const store = createMockStore();
const task1 = makeTask("HAI-001", "in-progress");
const task2 = makeTask("HAI-002", "in-progress");
const taskDone = makeTask("HAI-003", "done");
const task1 = makeTask("KB-001", "in-progress");
const task2 = makeTask("KB-002", "in-progress");
const taskDone = makeTask("KB-003", "done");
store.listTasks.mockResolvedValue([task1, task2, taskDone]);
mockAgentSuccess();
@@ -152,18 +152,18 @@ describe("In-progress task resume after restart", () => {
// Wait for async execute calls to complete
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);
});
it("resumed task reuses existing worktree — no git worktree add called", async () => {
const store = createMockStore();
const task = makeTask("HAI-010", "in-progress", {
worktree: "/tmp/wt/HAI-010",
const task = makeTask("KB-010", "in-progress", {
worktree: "/tmp/wt/KB-010",
});
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("HAI-010", "in-progress", {
worktree: "/tmp/wt/HAI-010",
store.getTask.mockResolvedValue(makeTaskDetail("KB-010", "in-progress", {
worktree: "/tmp/wt/KB-010",
}));
// 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 () => {
const store = createMockStore();
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.getTask.mockResolvedValue(makeTaskDetail("HAI-020", "in-progress", {
store.getTask.mockResolvedValue(makeTaskDetail("KB-020", "in-progress", {
steps,
currentStep: 3,
}));
@@ -217,9 +217,9 @@ describe("In-progress task resume after restart", () => {
...DEFAULT_SETTINGS,
worktreeInitCommand: "pnpm install",
});
const task = makeTask("HAI-030", "in-progress");
const task = makeTask("KB-030", "in-progress");
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
mockAgentSuccess();
@@ -240,8 +240,8 @@ describe("In-progress task resume after restart", () => {
it("resumeOrphaned() logs 'Resumed after engine restart' for each orphaned task", async () => {
const store = createMockStore();
const task1 = makeTask("HAI-040", "in-progress");
const task2 = makeTask("HAI-041", "in-progress");
const task1 = makeTask("KB-040", "in-progress");
const task2 = makeTask("KB-041", "in-progress");
store.listTasks.mockResolvedValue([task1, task2]);
store.getTask.mockImplementation(async (id: string) =>
makeTaskDetail(id, "in-progress"),
@@ -253,25 +253,25 @@ describe("In-progress task resume after restart", () => {
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
expect(store.logEntry).toHaveBeenCalledWith("HAI-040", "Resumed after engine restart");
expect(store.logEntry).toHaveBeenCalledWith("HAI-041", "Resumed after engine restart");
expect(store.logEntry).toHaveBeenCalledWith("KB-040", "Resumed after engine restart");
expect(store.logEntry).toHaveBeenCalledWith("KB-041", "Resumed after engine restart");
});
});
// ── Step 3: In-review merge re-queue tests ────────────────────────────────
//
// 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,
// and error handling with git reset --merge cleanup.
describe("In-review merge handling after restart", () => {
it("aiMergeTask validates task is in 'in-review' before merging", async () => {
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(
"Cannot merge HAI-050: task is in 'in-progress', must be in 'in-review'",
await expect(aiMergeTask(store, "/tmp/root", "KB-050")).rejects.toThrow(
"Cannot merge KB-050: task is in 'in-progress', must be in 'in-review'",
);
// 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 () => {
const store = createMockStore();
store.getTask.mockResolvedValue(makeTaskDetail("HAI-051", "in-review"));
store.moveTask.mockResolvedValue(makeTask("HAI-051", "done"));
store.getTask.mockResolvedValue(makeTaskDetail("KB-051", "in-review"));
store.moveTask.mockResolvedValue(makeTask("KB-051", "done"));
// Branch exists, merge succeeds, no conflicts
mockedExecSync.mockImplementation((cmd: any) => {
@@ -294,16 +294,16 @@ describe("In-review merge handling after restart", () => {
mockAgentSuccess();
await aiMergeTask(store, "/tmp/root", "HAI-051");
await aiMergeTask(store, "/tmp/root", "KB-051");
// 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)
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 () => {
const taskIds = ["HAI-052", "HAI-053", "HAI-054"];
const taskIds = ["KB-052", "KB-053", "KB-054"];
for (const taskId of taskIds) {
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 () => {
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
mockedExecSync.mockImplementation((cmd: any) => {
@@ -343,8 +343,8 @@ describe("In-review merge handling after restart", () => {
},
} as any);
await expect(aiMergeTask(store, "/tmp/root", "HAI-055")).rejects.toThrow(
"AI merge failed for HAI-055: merge agent crashed",
await expect(aiMergeTask(store, "/tmp/root", "KB-055")).rejects.toThrow(
"AI merge failed for KB-055: merge agent crashed",
);
// Should have attempted git reset --merge cleanup
@@ -354,13 +354,13 @@ describe("In-review merge handling after restart", () => {
expect(resetCalls.length).toBeGreaterThan(0);
// 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 () => {
const store = createMockStore();
store.getTask.mockResolvedValue(makeTaskDetail("HAI-056", "in-review"));
store.moveTask.mockResolvedValue(makeTask("HAI-056", "done"));
store.getTask.mockResolvedValue(makeTaskDetail("KB-056", "in-review"));
store.moveTask.mockResolvedValue(makeTask("KB-056", "done"));
// git rev-parse --verify throws (branch not found)
mockedExecSync.mockImplementation((cmd: any) => {
@@ -370,11 +370,11 @@ describe("In-review merge handling after restart", () => {
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.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", () => {
it("TriageProcessor.start() after restart picks up triage tasks (processing set is fresh)", async () => {
const store = createMockStore();
const triageTask1 = makeTask("HAI-060", "triage");
const triageTask2 = makeTask("HAI-061", "triage");
const triageTask1 = makeTask("KB-060", "triage");
const triageTask2 = makeTask("KB-061", "triage");
store.listTasks.mockResolvedValue([triageTask1, triageTask2]);
store.getTask.mockImplementation(async (id: string) =>
makeTaskDetail(id, "triage"),
@@ -402,15 +402,15 @@ describe("Triage re-pick after restart", () => {
triage.stop();
// Both triage tasks should have been picked up for specification
expect(store.updateTask).toHaveBeenCalledWith("HAI-060", { status: "specifying" });
expect(store.updateTask).toHaveBeenCalledWith("HAI-061", { status: "specifying" });
expect(store.updateTask).toHaveBeenCalledWith("KB-060", { status: "specifying" });
expect(store.updateTask).toHaveBeenCalledWith("KB-061", { status: "specifying" });
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
});
it("specifyTask() skips task already in processing set (no double-specification)", async () => {
const store = createMockStore();
const task = makeTask("HAI-062", "triage");
store.getTask.mockResolvedValue(makeTaskDetail("HAI-062", "triage"));
const task = makeTask("KB-062", "triage");
store.getTask.mockResolvedValue(makeTaskDetail("KB-062", "triage"));
// Slow agent to keep task in processing
let resolvePrompt: Function;
@@ -444,7 +444,7 @@ describe("Triage re-pick after restart", () => {
describe("Scheduler after restart", () => {
it("schedule() moves todo tasks to in-progress when deps are satisfied", async () => {
const store = createMockStore();
const todoTask = makeTask("HAI-070", "todo");
const todoTask = makeTask("KB-070", "todo");
store.listTasks.mockResolvedValue([todoTask]);
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS });
@@ -463,16 +463,16 @@ describe("Scheduler after restart", () => {
await new Promise((r) => setTimeout(r, 50));
scheduler.stop();
expect(store.moveTask).toHaveBeenCalledWith("HAI-070", "in-progress");
expect(store.updateTask).toHaveBeenCalledWith("HAI-070", { status: null, blockedBy: null });
expect(store.moveTask).toHaveBeenCalledWith("KB-070", "in-progress");
expect(store.updateTask).toHaveBeenCalledWith("KB-070", { status: null, blockedBy: null });
expect(onSchedule).toHaveBeenCalledWith(todoTask);
});
it("schedule() respects dependency ordering — blocked tasks stay in todo", async () => {
const store = createMockStore();
const depTask = makeTask("HAI-071", "in-progress");
const blockedTask = makeTask("HAI-072", "todo", {
dependencies: ["HAI-071"],
const depTask = makeTask("KB-071", "in-progress");
const blockedTask = makeTask("KB-072", "todo", {
dependencies: ["KB-071"],
});
store.listTasks.mockResolvedValue([depTask, blockedTask]);
store.getSettings.mockResolvedValue({ ...DEFAULT_SETTINGS });
@@ -489,19 +489,19 @@ describe("Scheduler after restart", () => {
scheduler.stop();
// Task should NOT have been moved
expect(store.moveTask).not.toHaveBeenCalledWith("HAI-072", "in-progress");
expect(onBlocked).toHaveBeenCalledWith(blockedTask, ["HAI-071"]);
expect(store.moveTask).not.toHaveBeenCalledWith("KB-072", "in-progress");
expect(onBlocked).toHaveBeenCalledWith(blockedTask, ["KB-071"]);
});
it("full column coverage: restart with tasks in every column", async () => {
const store = createMockStore();
// Tasks across all columns
const triageTask = makeTask("HAI-080", "triage");
const todoTask = makeTask("HAI-081", "todo");
const inProgressTask = makeTask("HAI-082", "in-progress");
const inReviewTask = makeTask("HAI-083", "in-review");
const doneTask = makeTask("HAI-084", "done");
const triageTask = makeTask("KB-080", "triage");
const todoTask = makeTask("KB-081", "todo");
const inProgressTask = makeTask("KB-082", "in-progress");
const inReviewTask = makeTask("KB-083", "in-review");
const doneTask = makeTask("KB-084", "done");
const allTasks = [triageTask, todoTask, inProgressTask, inReviewTask, doneTask];
store.listTasks.mockResolvedValue(allTasks);
@@ -521,7 +521,7 @@ describe("Scheduler after restart", () => {
await new Promise((r) => setTimeout(r, 100));
triage.stop();
expect(store.updateTask).toHaveBeenCalledWith("HAI-080", { status: "specifying" });
expect(store.updateTask).toHaveBeenCalledWith("KB-080", { status: "specifying" });
// 2. Scheduler moves todo → in-progress
vi.clearAllMocks();
@@ -533,7 +533,7 @@ describe("Scheduler after restart", () => {
await new Promise((r) => setTimeout(r, 50));
scheduler.stop();
expect(store.moveTask).toHaveBeenCalledWith("HAI-081", "in-progress");
expect(store.moveTask).toHaveBeenCalledWith("KB-081", "in-progress");
// 3. Executor resumes in-progress tasks
vi.clearAllMocks();
@@ -548,13 +548,13 @@ describe("Scheduler after restart", () => {
await executor.resumeOrphaned();
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 = [
...store.updateTask.mock.calls,
...store.moveTask.mock.calls,
].filter((call) => call[0] === "HAI-084");
].filter((call) => call[0] === "KB-084");
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 () => {
const sem = new AgentSemaphore(2);
const store = createMockStore();
const task = makeTask("HAI-090", "in-progress");
const task = makeTask("KB-090", "in-progress");
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)
mockedCreateHaiAgent.mockResolvedValue({
@@ -596,7 +596,7 @@ describe("Crash scenario edge cases", () => {
// Verify by calling resumeOrphaned again — it should try to execute again
vi.clearAllMocks();
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("HAI-090", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("KB-090", "in-progress"));
mockAgentSuccess();
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 () => {
const store = createMockStore();
store.getTask.mockResolvedValue(makeTaskDetail("HAI-091", "in-review"));
store.getTask.mockResolvedValue(makeTaskDetail("KB-091", "in-review"));
mockedExecSync.mockReturnValue(Buffer.from(""));
@@ -620,7 +620,7 @@ describe("Crash scenario edge cases", () => {
},
} 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
const resetCalls = mockedExecSync.mock.calls.filter(
@@ -629,17 +629,17 @@ describe("Crash scenario edge cases", () => {
expect(resetCalls.length).toBeGreaterThan(0);
// 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
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 () => {
const store = createMockStore();
const task = makeTask("HAI-092", "in-progress");
const task = makeTask("KB-092", "in-progress");
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("HAI-092", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("KB-092", "in-progress"));
let resolvePrompt: Function;
mockedCreateHaiAgent.mockResolvedValue({
@@ -676,9 +676,9 @@ describe("Crash scenario edge cases", () => {
await sem.acquire();
expect(sem.activeCount).toBe(1);
const task = makeTask("HAI-093", "in-progress");
const task = makeTask("KB-093", "in-progress");
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("HAI-093", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("KB-093", "in-progress"));
// Agent creation itself fails
mockedCreateHaiAgent.mockRejectedValue(new Error("cannot create agent"));

View File

@@ -1,13 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("./pi.js", () => ({
createHaiAgent: vi.fn(),
createKbAgent: vi.fn(),
}));
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) {
return {
@@ -30,13 +30,13 @@ describe("reviewStep — model settings threading", () => {
vi.clearAllMocks();
});
it("passes defaultProvider and defaultModelId to createHaiAgent when provided", async () => {
it("passes defaultProvider and defaultModelId to createKbAgent when provided", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
await reviewStep(
"/tmp/worktree", "HAI-100", 1, "Test Step", "plan", "# prompt",
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
defaultProvider: "anthropic",
@@ -56,7 +56,7 @@ describe("reviewStep — model settings threading", () => {
);
await reviewStep(
"/tmp/worktree", "HAI-100", 1, "Test Step", "plan", "# prompt",
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
undefined,
{},
);
@@ -73,7 +73,7 @@ describe("reviewStep — model settings threading", () => {
);
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");

View File

@@ -8,7 +8,7 @@
* - 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.
@@ -136,7 +136,7 @@ export async function reviewStep(
);
// Spawn a reviewer agent with read-only tools
const { session } = await createHaiAgent({
const { session } = await createKbAgent({
cwd,
systemPrompt: REVIEWER_SYSTEM_PROMPT,
tools: "readonly",

View File

@@ -3,7 +3,7 @@ import { Scheduler } from "./scheduler.js";
function makeTask(overrides: Record<string, unknown> = {}) {
return {
id: "HAI-001",
id: "KB-001",
title: "Test Task",
column: "todo",
dependencies: [],
@@ -49,40 +49,40 @@ describe("Scheduler concurrency", () => {
it("respects maxConcurrent with only in-progress tasks", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "in-progress" }),
makeTask({ id: "HAI-003", column: "todo" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "in-progress" }),
makeTask({ id: "KB-003", column: "todo" }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
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();
});
it("counts specifying tasks toward concurrency", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "triage", status: "specifying" }),
makeTask({ id: "HAI-003", column: "todo" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "triage", status: "specifying" }),
makeTask({ id: "KB-003", column: "todo" }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
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();
});
it("blocks all todo tasks when specifying fills all slots", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "triage", status: "specifying" }),
makeTask({ id: "HAI-002", column: "triage", status: "specifying" }),
makeTask({ id: "HAI-003", column: "todo" }),
makeTask({ id: "HAI-004", column: "todo" }),
makeTask({ id: "KB-001", column: "triage", status: "specifying" }),
makeTask({ id: "KB-002", column: "triage", status: "specifying" }),
makeTask({ id: "KB-003", column: "todo" }),
makeTask({ id: "KB-004", column: "todo" }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
@@ -94,9 +94,9 @@ describe("Scheduler concurrency", () => {
it("allows scheduling when mixed slots leave room", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "triage", status: "specifying" }),
makeTask({ id: "HAI-003", column: "todo" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "triage", status: "specifying" }),
makeTask({ id: "KB-003", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
@@ -111,14 +111,14 @@ describe("Scheduler concurrency", () => {
await runSchedule(scheduler);
// 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 () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "triage" }), // no status: "specifying"
makeTask({ id: "HAI-003", column: "todo" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "triage" }), // no status: "specifying"
makeTask({ id: "KB-003", column: "todo" }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
@@ -126,7 +126,7 @@ describe("Scheduler concurrency", () => {
await runSchedule(scheduler);
// 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 () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "todo" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "todo" }),
];
const store = createMockStore(tasks);
// Start with maxConcurrent: 1 — no room
@@ -159,7 +159,7 @@ describe("Scheduler dynamic settings reload", () => {
await runSchedule(scheduler);
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({
maxConcurrent: 2,
maxWorktrees: 4,
@@ -169,14 +169,14 @@ describe("Scheduler dynamic settings reload", () => {
});
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 () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "in-review", worktree: "/tmp/wt" }),
makeTask({ id: "HAI-003", column: "todo" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "in-review", worktree: "/tmp/wt" }),
makeTask({ id: "KB-003", column: "todo" }),
];
const store = createMockStore(tasks);
// Start with maxWorktrees: 2 — no room (2 active worktrees)
@@ -192,7 +192,7 @@ describe("Scheduler dynamic settings reload", () => {
await runSchedule(scheduler);
expect(store.moveTask).not.toHaveBeenCalled();
// Bump maxWorktrees to 3 — room for HAI-003
// Bump maxWorktrees to 3 — room for KB-003
store.getSettings.mockResolvedValue({
maxConcurrent: 4,
maxWorktrees: 3,
@@ -202,7 +202,7 @@ describe("Scheduler dynamic settings reload", () => {
});
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 () => {
@@ -254,8 +254,8 @@ describe("Scheduler file-scope overlap", () => {
it("sets status 'queued' for a todo task deferred due to file scope overlap", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "todo" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "todo" }),
];
const store = createMockStore(tasks);
// Enable file scope grouping
@@ -268,24 +268,24 @@ describe("Scheduler file-scope overlap", () => {
});
// Both tasks share overlapping file scopes
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
if (id === "HAI-001") return ["packages/shared/utils.ts"];
if (id === "HAI-002") return ["packages/shared/utils.ts"];
if (id === "KB-001") return ["packages/shared/utils.ts"];
if (id === "KB-002") return ["packages/shared/utils.ts"];
return [];
});
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
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();
// HAI-002 should have status set to "queued" with blockedBy
expect(store.updateTask).toHaveBeenCalledWith("HAI-002", { status: "queued", blockedBy: "HAI-001" });
// KB-002 should have status set to "queued" with blockedBy
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 () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "todo" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
@@ -296,20 +296,20 @@ describe("Scheduler file-scope overlap", () => {
autoMerge: false,
});
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
if (id === "HAI-001") return ["packages/shared/utils.ts"];
if (id === "HAI-002") return ["packages/shared/utils.ts"];
if (id === "KB-001") return ["packages/shared/utils.ts"];
if (id === "KB-002") return ["packages/shared/utils.ts"];
return [];
});
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
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 () => {
const tasks = [
makeTask({ id: "HAI-001", column: "todo" }),
makeTask({ id: "KB-001", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
@@ -323,15 +323,15 @@ describe("Scheduler file-scope overlap", () => {
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", { status: null, blockedBy: null });
expect(store.moveTask).toHaveBeenCalledWith("HAI-001", "in-progress");
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: null, blockedBy: null });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-progress");
});
it("does not emit console.log when deferring due to file overlap", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "todo" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
@@ -342,8 +342,8 @@ describe("Scheduler file-scope overlap", () => {
autoMerge: false,
});
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
if (id === "HAI-001") return ["packages/shared/utils.ts"];
if (id === "HAI-002") return ["packages/shared/utils.ts"];
if (id === "KB-001") return ["packages/shared/utils.ts"];
if (id === "KB-002") return ["packages/shared/utils.ts"];
return [];
});
@@ -358,8 +358,8 @@ describe("Scheduler file-scope overlap", () => {
it("does not set status 'queued' when file scopes do not overlap", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "todo" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
@@ -370,16 +370,16 @@ describe("Scheduler file-scope overlap", () => {
autoMerge: false,
});
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
if (id === "HAI-001") return ["packages/a/file.ts"];
if (id === "HAI-002") return ["packages/b/file.ts"];
if (id === "KB-001") return ["packages/a/file.ts"];
if (id === "KB-002") return ["packages/b/file.ts"];
return [];
});
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
await runSchedule(scheduler);
// HAI-002 should be moved (no overlap)
expect(store.moveTask).toHaveBeenCalledWith("HAI-002", "in-progress");
// KB-002 should be moved (no overlap)
expect(store.moveTask).toHaveBeenCalledWith("KB-002", "in-progress");
});
});
@@ -395,7 +395,7 @@ describe("Scheduler paused tasks", () => {
it("does not schedule paused todo tasks", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "todo", paused: true }),
makeTask({ id: "KB-001", column: "todo", paused: true }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
@@ -407,20 +407,20 @@ describe("Scheduler paused tasks", () => {
it("schedules non-paused todo tasks normally", async () => {
const tasks = [
makeTask({ id: "HAI-001", column: "todo", paused: false }),
makeTask({ id: "KB-001", column: "todo", paused: false }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
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 () => {
const tasks = [
makeTask({ id: "HAI-001", column: "triage", status: "specifying", paused: true }),
makeTask({ id: "HAI-002", column: "todo" }),
makeTask({ id: "KB-001", column: "triage", status: "specifying", paused: true }),
makeTask({ id: "KB-002", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
@@ -434,8 +434,8 @@ describe("Scheduler paused tasks", () => {
await runSchedule(scheduler);
// The paused specifying task doesn't consume a slot, so HAI-002 should be scheduled
expect(store.moveTask).toHaveBeenCalledWith("HAI-002", "in-progress");
// The paused specifying task doesn't consume a slot, so KB-002 should be scheduled
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 () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "in-progress" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "in-progress" }),
];
const store = createMockStore(tasks);
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 () => {
const tasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "in-progress" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "in-progress" }),
];
const store = createMockStore(tasks);
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 () => {
const maxedTasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "HAI-002", column: "in-progress" }),
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "in-progress" }),
];
const freeTasks = [
makeTask({ id: "HAI-001", column: "in-progress" }),
makeTask({ id: "KB-001", column: "in-progress" }),
];
const store = createMockStore(maxedTasks);

View File

@@ -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 { schedulerLog } from "./logger.js";

View File

@@ -1,22 +1,22 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AgentSemaphore } from "./concurrency.js";
// Mock createHaiAgent before importing TriageProcessor
// Mock createKbAgent before importing TriageProcessor
vi.mock("./pi.js", () => ({
createHaiAgent: vi.fn(),
createKbAgent: vi.fn(),
}));
import { TriageProcessor, buildSpecificationPrompt, type AttachmentContent } from "./triage.js";
import { createHaiAgent } from "./pi.js";
import type { TaskDetail } from "@hai/core";
import { createKbAgent } from "./pi.js";
import type { TaskDetail } from "@kb/core";
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
function createMockStore(tasks: any[] = []) {
return {
listTasks: vi.fn().mockResolvedValue(tasks),
getTask: vi.fn().mockResolvedValue({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test task",
column: "triage",
@@ -43,7 +43,7 @@ function createMockStore(tasks: any[] = []) {
function createMockTaskDetail(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "HAI-001",
id: "KB-001",
title: "Test Task",
description: "A test task",
column: "triage",
@@ -79,7 +79,7 @@ describe("TriageProcessor with semaphore", () => {
const triage = new TriageProcessor(store, "/tmp/test", { semaphore: sem });
await triage.specifyTask({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "triage",
@@ -111,7 +111,7 @@ describe("TriageProcessor with semaphore", () => {
});
await triage.specifyTask({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "triage",
@@ -163,9 +163,9 @@ describe("TriageProcessor with semaphore", () => {
});
await Promise.all([
triage.specifyTask(task("HAI-001")),
triage.specifyTask(task("HAI-002")),
triage.specifyTask(task("HAI-003")),
triage.specifyTask(task("KB-001")),
triage.specifyTask(task("KB-002")),
triage.specifyTask(task("KB-003")),
]);
expect(maxConcurrent).toBe(1);
@@ -223,7 +223,7 @@ describe("TriageProcessor paused tasks", () => {
it("skips paused triage tasks in poll()", async () => {
const pausedTask = {
id: "HAI-001",
id: "KB-001",
title: "Paused",
description: "Paused task",
column: "triage" as const,
@@ -255,7 +255,7 @@ describe("TriageProcessor paused tasks", () => {
it("processes non-paused triage tasks normally", async () => {
const normalTask = {
id: "HAI-002",
id: "KB-002",
title: "Normal",
description: "Normal task",
column: "triage" as const,
@@ -280,14 +280,14 @@ describe("TriageProcessor paused tasks", () => {
await (triage as any).poll();
// 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", () => {
it("includes project commands when testCommand is set", () => {
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,
maxWorktrees: 4,
pollIntervalMs: 15000,
@@ -303,7 +303,7 @@ describe("buildSpecificationPrompt", () => {
it("includes project commands when buildCommand is set", () => {
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,
maxWorktrees: 4,
pollIntervalMs: 15000,
@@ -318,7 +318,7 @@ describe("buildSpecificationPrompt", () => {
it("includes both commands when both are set", () => {
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,
maxWorktrees: 4,
pollIntervalMs: 15000,
@@ -334,7 +334,7 @@ describe("buildSpecificationPrompt", () => {
it("omits project commands section when neither command is set", () => {
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,
maxWorktrees: 4,
pollIntervalMs: 15000,
@@ -347,7 +347,7 @@ describe("buildSpecificationPrompt", () => {
it("omits project commands section when settings is undefined", () => {
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");
});
@@ -357,7 +357,7 @@ describe("buildSpecificationPrompt", () => {
const attachmentContents: AttachmentContent[] = [
{ 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("### error.log (text/plain)");
@@ -369,7 +369,7 @@ describe("buildSpecificationPrompt", () => {
const attachmentContents: AttachmentContent[] = [
{ 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("**screenshot.png** (image/png)");
@@ -382,7 +382,7 @@ describe("buildSpecificationPrompt", () => {
{ originalName: "screenshot.png", mimeType: "image/png", text: null },
{ 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("### config.json (application/json)");
@@ -391,14 +391,14 @@ describe("buildSpecificationPrompt", () => {
it("omits attachments section when no attachments", () => {
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");
});
it("omits attachments section when attachmentContents is undefined", () => {
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");
});
@@ -412,7 +412,7 @@ function createEnoentError(path = "/fake/path"): NodeJS.ErrnoException {
}
const dummyTask = {
id: "HAI-099",
id: "KB-099",
title: "Deleted task",
description: "This task was deleted",
column: "triage" as const,
@@ -485,7 +485,7 @@ describe("TriageProcessor deleted task handling", () => {
await triage.specifyTask(dummyTask);
// 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();
});
});
@@ -515,7 +515,7 @@ describe("TriageProcessor agent log persistence", () => {
const triage = new TriageProcessor(store, "/tmp/test", {});
await triage.specifyTask({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "triage",
@@ -528,7 +528,7 @@ describe("TriageProcessor agent log persistence", () => {
});
// 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 () => {
@@ -549,7 +549,7 @@ describe("TriageProcessor agent log persistence", () => {
const triage = new TriageProcessor(store, "/tmp/test", {});
await triage.specifyTask({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "triage",
@@ -561,7 +561,7 @@ describe("TriageProcessor agent log persistence", () => {
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 () => {
@@ -583,7 +583,7 @@ describe("TriageProcessor agent log persistence", () => {
const triage = new TriageProcessor(store, "/tmp/test", { onAgentText });
await triage.specifyTask({
id: "HAI-001",
id: "KB-001",
title: "Test",
description: "Test",
column: "triage",
@@ -595,7 +595,7 @@ describe("TriageProcessor agent log persistence", () => {
updatedAt: new Date().toISOString(),
});
expect(onAgentText).toHaveBeenCalledWith("HAI-001", "hi");
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "hi", "text");
expect(onAgentText).toHaveBeenCalledWith("KB-001", "hi");
expect(store.appendAgentLog).toHaveBeenCalledWith("KB-001", "hi", "text");
});
});

View File

@@ -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, type Static } from "@mariozechner/pi-ai";
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 { AgentLogger } from "./agent-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.
@@ -81,7 +81,7 @@ Follow this structure exactly:
### Step {N}: Documentation & Delivery
- [ ] 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
@@ -250,7 +250,7 @@ export class TriageProcessor {
await this.store.updateTask(task.id, { status: "specifying" });
const detail = await this.store.getTask(task.id);
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 agentLogger = new AgentLogger({
@@ -264,7 +264,7 @@ export class TriageProcessor {
},
});
const { session } = await createHaiAgent({
const { session } = await createKbAgent({
cwd: this.rootDir,
systemPrompt: TRIAGE_SYSTEM_PROMPT,
tools: "coding",
@@ -333,7 +333,7 @@ export class TriageProcessor {
const store = this.store;
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 = {
@@ -431,7 +431,7 @@ export async function readAttachmentContents(
const { join } = await import("node:path");
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 {
if (IMAGE_MIME_TYPES.has(att.mimeType)) {

View File

@@ -8,7 +8,7 @@ describe("generateWorktreeName", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "hai-wt-test-"));
tempDir = mkdtempSync(join(tmpdir(), "kb-wt-test-"));
});
afterEach(() => {

View File

@@ -120,7 +120,7 @@ describe("WorktreePool", () => {
describe("prepareForTask", () => {
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]);
expect(calls).toContain("git checkout -- .");
@@ -128,18 +128,18 @@ describe("WorktreePool", () => {
});
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(
(c) => typeof c[0] === "string" && (c[0] as string).includes("checkout -B"),
);
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" });
});
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) {
expect(call[1]).toMatchObject({ cwd: "/tmp/my-worktree" });
@@ -153,12 +153,12 @@ describe("WorktreePool", () => {
});
// 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
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
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');
});
});
});

View File

@@ -82,7 +82,7 @@ export class WorktreePool {
* 3. `git checkout -B <branchName> main` — create/reset branch from main
*
* @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 {
// Clean tracked modifications

10
pnpm-lock.yaml generated
View File

@@ -20,13 +20,13 @@ importers:
packages/cli:
dependencies:
'@hai/core':
'@kb/core':
specifier: workspace:*
version: link:../core
'@hai/dashboard':
'@kb/dashboard':
specifier: workspace:*
version: link:../dashboard
'@hai/engine':
'@kb/engine':
specifier: workspace:*
version: link:../engine
'@mariozechner/pi-coding-agent':
@@ -60,7 +60,7 @@ importers:
packages/dashboard:
dependencies:
'@hai/core':
'@kb/core':
specifier: workspace:*
version: link:../core
'@types/multer':
@@ -124,7 +124,7 @@ importers:
packages/engine:
dependencies:
'@hai/core':
'@kb/core':
specifier: workspace:*
version: link:../core
'@mariozechner/pi-ai':