mirror of
https://github.com/Hestia-Homes/agentic-toolkit.git
synced 2026-06-08 11:37:26 +00:00
feat: scaffold agentic-toolkit (runner + skills + setup)
Initial implementation of Domna's agentic toolkit per PRD #1: - Runner CLI (src/cli.ts) wrapping sandcastle.run() with Docker provider - Pure modules: PhaseScheduler, PromptBuilder, FailureHandler with tests - Project Status v2 GraphQL client + parsers with tests - BranchManager (git/gh wrapper) and LoopOrchestrator (per-tick algorithm) - Variant-aware: per-ticket (one PR per issue, phase-gated, exit between phases) vs single-pr (one PR for the whole DAG, halt on failure) - /to-project skill that creates a repo-level project, configures the Status schema the runner expects, and sets initial issue statuses - setup.sh that installs Matt Pocock skills + Domna skills via npx skills Out of scope at v1: remote runners, Slack notifications, stacked PRs, cross-repo projects, SHA-pinning of upstream skills (tracks HEAD until the skills CLI supports repo#sha). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
commit
1d8a77b29b
22 changed files with 2066 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
coverage
|
||||||
|
.vitest-cache
|
||||||
107
README.md
Normal file
107
README.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# agentic-toolkit
|
||||||
|
|
||||||
|
Domna's agentic toolkit. Two things in one repo:
|
||||||
|
|
||||||
|
1. **A curated, version-pinned skill set** for Claude Code (Matt Pocock's skills + Domna's own), installable into any target repo with one script.
|
||||||
|
2. **A sandcastle-based runner** that executes a GitHub Project of issues against a target repo, in either per-ticket-PR mode or single-PR mode.
|
||||||
|
|
||||||
|
## Quick start (consume in a target repo)
|
||||||
|
|
||||||
|
From the root of any Domna repo:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Hestia-Homes/agentic-toolkit/main/setup.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs the curated skills into the repo and writes `skills-lock.json`. Re-run whenever the toolkit bumps its pinned versions.
|
||||||
|
|
||||||
|
After installation, run `/setup-matt-pocock-skills` once per repo to record the issue tracker, triage labels, and domain-doc layout.
|
||||||
|
|
||||||
|
## Running the runner
|
||||||
|
|
||||||
|
The runner is invoked from inside this repo, pointing at a target repo.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Docker Desktop running on macOS
|
||||||
|
- `GITHUB_TOKEN` env var with `repo` and `project` scopes
|
||||||
|
- A GitHub Project (v2) created via `/to-project` (or manually with the same Status schema)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/Hestia-Homes/agentic-toolkit.git
|
||||||
|
cd agentic-toolkit
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
GITHUB_TOKEN=ghp_xxx \
|
||||||
|
GITHUB_VIEWER_LOGIN=KhalimCK \
|
||||||
|
node bin/run-sandcastle.js run \
|
||||||
|
--project 7 \
|
||||||
|
--mode per-ticket \
|
||||||
|
--owner Hestia-Homes \
|
||||||
|
--repo assessment-model \
|
||||||
|
--target-repo ~/Documents/hestia/assessment-model
|
||||||
|
```
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
|
||||||
|
- `per-ticket` — one PR per issue, phase-gated. Runner exits between phases; re-run after PRs merge.
|
||||||
|
- `single-pr` — one PR for the whole DAG. Runner halts on any failure.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
/grill-me → /to-prd → /to-issues → /to-project → agentic-toolkit run --project N --mode <variant>
|
||||||
|
```
|
||||||
|
|
||||||
|
`to-project` lives in `skills/engineering/to-project/SKILL.md` and is installed by `setup.sh`.
|
||||||
|
|
||||||
|
## Architecture (modules in `src/modules/`)
|
||||||
|
|
||||||
|
| Module | Role |
|
||||||
|
|------------------------|------------------------------------------------------------------------------------------------|
|
||||||
|
| `PhaseScheduler` | Pure: topological sort of `Blocked by` → ordered phases. |
|
||||||
|
| `PromptBuilder` | Pure: build the per-ticket agent prompt. |
|
||||||
|
| `FailureHandler` | Pure state machine: retry / skip / halt given variant + retry count. |
|
||||||
|
| `ProjectStateClient` | GitHub Projects v2 + Issues GraphQL (read state, claim, set status, comment). |
|
||||||
|
| `BranchManager` | Git + `gh` ops in the target repo (push, open PR). |
|
||||||
|
| `AgentRunner` | Wraps `sandcastle.run()` with Docker provider and Claude Code agent. |
|
||||||
|
| `LoopOrchestrator` | Wires the above; runs the per-tick algorithm. |
|
||||||
|
|
||||||
|
### Variant differences
|
||||||
|
|
||||||
|
| Concern | per-ticket | single-pr |
|
||||||
|
|-----------------------|---------------------------|-----------------------------|
|
||||||
|
| Branches | one per issue | one per project, reused |
|
||||||
|
| PRs | one per issue | one for the whole DAG |
|
||||||
|
| Phase gates | yes (exit between phases) | no (topological order only) |
|
||||||
|
| HITL mid-run | issue parked; peers continue | runner halts |
|
||||||
|
| Failure after retry | skip + continue | halt |
|
||||||
|
|
||||||
|
### Project Status field
|
||||||
|
|
||||||
|
`/to-project` configures a single-select `Status` field with these options:
|
||||||
|
|
||||||
|
- `Backlog` — has unmet blockers.
|
||||||
|
- `Ready` — runner-pickable; AFK with all blockers Done.
|
||||||
|
- `In progress` — being executed by an agent right now.
|
||||||
|
- `In review` — PR open, waiting for human merge.
|
||||||
|
- `Needs human` — failed twice, or HITL.
|
||||||
|
- `Done` — issue closed (set automatically on PR merge by Projects' built-in workflow).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Pure modules (`PhaseScheduler`, `PromptBuilder`, `FailureHandler`, `ProjectStateClient` parsers) are unit-tested. Integration with sandcastle / git / GraphQL is exercised manually before each release.
|
||||||
|
|
||||||
|
## Out of scope (v1)
|
||||||
|
|
||||||
|
- Remote / parallel runners across machines (local-first).
|
||||||
|
- Slack / email failure notifications (issue comments only).
|
||||||
|
- Stacked PRs and phase branches.
|
||||||
|
- Cross-repo projects.
|
||||||
|
- Pinning Matt Pocock skills to a specific commit SHA — `setup.sh` tracks HEAD for now; SHA pinning will land when the upstream `skills` CLI supports `repo#sha`.
|
||||||
2
bin/run-sandcastle.js
Executable file
2
bin/run-sandcastle.js
Executable file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import "../dist/cli.js";
|
||||||
33
package.json
Normal file
33
package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "@domna/agentic-toolkit",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Domna's agentic toolkit: curated skills + sandcastle-based runner for executing GitHub Project work",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"agentic-toolkit": "./bin/run-sandcastle.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "tsx src/cli.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-hero/sandcastle": "^0.5.7",
|
||||||
|
"@octokit/graphql": "^8.1.1",
|
||||||
|
"@octokit/rest": "^21.0.2",
|
||||||
|
"commander": "^12.1.0",
|
||||||
|
"execa": "^9.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vitest": "^2.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
setup.sh
Executable file
73
setup.sh
Executable file
|
|
@ -0,0 +1,73 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Install Domna's curated skill set into the current repo.
|
||||||
|
#
|
||||||
|
# Run from the root of the target repo:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/Hestia-Homes/agentic-toolkit/main/setup.sh | bash
|
||||||
|
# Or, if you've cloned agentic-toolkit:
|
||||||
|
# bash /path/to/agentic-toolkit/setup.sh
|
||||||
|
#
|
||||||
|
# What this does:
|
||||||
|
# 1. Adds Matt Pocock's skills (mattpocock/skills) at the version Domna trusts.
|
||||||
|
# 2. Adds Domna's own skills (Hestia-Homes/agentic-toolkit).
|
||||||
|
# 3. Writes/updates skills-lock.json so the install is reproducible.
|
||||||
|
#
|
||||||
|
# To upgrade Matt's skills across all Domna repos:
|
||||||
|
# - Bump MATTPOCOCK_REF in this script (agentic-toolkit repo).
|
||||||
|
# - Devs re-run setup.sh in their target repos.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- pinned versions -----------------------------------------------------------
|
||||||
|
# Bump these refs in agentic-toolkit when Domna decides to upgrade. Devs in
|
||||||
|
# target repos pick up the new pins on their next setup.sh run.
|
||||||
|
MATTPOCOCK_SOURCE="mattpocock/skills"
|
||||||
|
MATTPOCOCK_REF="" # leave empty to track HEAD; set to a commit SHA to pin
|
||||||
|
|
||||||
|
DOMNA_SOURCE="Hestia-Homes/agentic-toolkit"
|
||||||
|
DOMNA_REF="" # leave empty to track HEAD; set to a commit SHA to pin
|
||||||
|
|
||||||
|
AGENT_TARGET="claude" # Claude Code agent install layout
|
||||||
|
|
||||||
|
# --- guards --------------------------------------------------------------------
|
||||||
|
if ! command -v npx >/dev/null 2>&1; then
|
||||||
|
echo "error: npx is required (install Node.js >= 20)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -d .git ]]; then
|
||||||
|
echo "error: run this script from the root of a git repository." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- install -------------------------------------------------------------------
|
||||||
|
mattpocock_pkg="$MATTPOCOCK_SOURCE${MATTPOCOCK_REF:+#$MATTPOCOCK_REF}"
|
||||||
|
domna_pkg="$DOMNA_SOURCE${DOMNA_REF:+#$DOMNA_REF}"
|
||||||
|
|
||||||
|
echo "==> Installing Matt Pocock skills from $mattpocock_pkg"
|
||||||
|
npx --yes skills@latest add "$mattpocock_pkg" \
|
||||||
|
--skill '*' \
|
||||||
|
--agent "$AGENT_TARGET" \
|
||||||
|
--copy \
|
||||||
|
--yes
|
||||||
|
|
||||||
|
echo "==> Installing Domna skills from $domna_pkg"
|
||||||
|
npx --yes skills@latest add "$domna_pkg" \
|
||||||
|
--skill '*' \
|
||||||
|
--agent "$AGENT_TARGET" \
|
||||||
|
--copy \
|
||||||
|
--yes
|
||||||
|
|
||||||
|
# --- post-install reminder -----------------------------------------------------
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
==> Done. Next steps:
|
||||||
|
|
||||||
|
1. Run /setup-matt-pocock-skills (one-time per repo) to record the issue
|
||||||
|
tracker, triage labels, and domain-doc layout.
|
||||||
|
|
||||||
|
2. Commit the skills-lock.json + .claude/skills/ (or whichever directory
|
||||||
|
the installer wrote to) so teammates can reproduce.
|
||||||
|
|
||||||
|
3. Re-run setup.sh whenever agentic-toolkit bumps its pinned versions.
|
||||||
|
|
||||||
|
EOF
|
||||||
78
skills/engineering/to-project/SKILL.md
Normal file
78
skills/engineering/to-project/SKILL.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
name: to-project
|
||||||
|
description: Create a GitHub Project (v2) for the issues just produced by /to-issues, configure the Status field schema the agentic-toolkit runner expects, and set initial statuses (AFK no-blockers → Ready, AFK with blockers → Backlog, HITL → Needs human). Use after /to-issues to make a project the runner can execute against.
|
||||||
|
---
|
||||||
|
|
||||||
|
# To Project
|
||||||
|
|
||||||
|
Create a repo-level GitHub Project (v2) from the issues `/to-issues` just produced, and prepare it so the Domna agentic-toolkit runner can execute against it without further configuration.
|
||||||
|
|
||||||
|
The issue tracker and issue numbers should already be known from the conversation context (the most recent `/to-issues` run). If they aren't, ask the user for the issue numbers explicitly.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### 1. Gather context
|
||||||
|
|
||||||
|
Confirm with the user:
|
||||||
|
|
||||||
|
- **Project name** — defaults to the PRD title if a PRD is in context.
|
||||||
|
- **Project description** — defaults to a one-line summary of the PRD.
|
||||||
|
- **Repo** — the repo that owns the project. Defaults to the current `gh` repo.
|
||||||
|
- **Issue set** — the issue numbers to add. Defaults to issues created in the most recent `/to-issues` run.
|
||||||
|
|
||||||
|
### 2. Create the project
|
||||||
|
|
||||||
|
Use `gh project create` (or the GraphQL `createProjectV2` mutation) at the repo level. Project visibility defaults to whatever the repo's default is. Capture the project number and node id from the response.
|
||||||
|
|
||||||
|
### 3. Configure the Status field
|
||||||
|
|
||||||
|
The runner requires a single-select field named **`Status`** with these options, in this order:
|
||||||
|
|
||||||
|
1. `Backlog`
|
||||||
|
2. `Ready`
|
||||||
|
3. `In progress`
|
||||||
|
4. `In review`
|
||||||
|
5. `Needs human`
|
||||||
|
6. `Done`
|
||||||
|
|
||||||
|
If the project came with a default `Status` field, replace its options to match exactly — extra options are fine; missing ones must be added. If no `Status` field exists, create one.
|
||||||
|
|
||||||
|
### 4. Add issues to the project
|
||||||
|
|
||||||
|
For each issue in the issue set, add it to the project as an item.
|
||||||
|
|
||||||
|
### 5. Set initial statuses
|
||||||
|
|
||||||
|
For each issue, decide the starting status based on its labels and `Blocked by` references:
|
||||||
|
|
||||||
|
| Issue properties | Starting status |
|
||||||
|
|-------------------------------------------------|------------------|
|
||||||
|
| Has `hitl` or `ready-for-human` label | `Needs human` |
|
||||||
|
| Has `Blocked by` references to issues *in this set* | `Backlog` |
|
||||||
|
| Otherwise (AFK, no remaining blockers) | `Ready` |
|
||||||
|
|
||||||
|
Apply the status via the `updateProjectV2ItemFieldValue` mutation.
|
||||||
|
|
||||||
|
### 6. Comment on each issue
|
||||||
|
|
||||||
|
For each issue in the project, post a comment linking back to the project URL so the issue page shows the project context. One sentence is enough; example:
|
||||||
|
|
||||||
|
> Tracked in project: <project URL>
|
||||||
|
|
||||||
|
### 7. Confirm to the user
|
||||||
|
|
||||||
|
Print:
|
||||||
|
|
||||||
|
- Project URL
|
||||||
|
- Number of issues added, broken down by initial status (e.g. "3 Ready, 2 Backlog, 1 Needs human")
|
||||||
|
- Suggested next command, e.g.:
|
||||||
|
```
|
||||||
|
cd path/to/agentic-toolkit
|
||||||
|
GITHUB_TOKEN=... npx . run --project <N> --mode per-ticket --owner <OWNER> --repo <REPO> --target-repo <TARGET>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The project is repo-level, not org-level, by design (smaller blast radius, simpler permissions). If you need a cross-repo project, do it manually for now.
|
||||||
|
- Projects created by this skill are intended to be short-lived: scoped to one PRD's worth of work, archived or deleted on completion.
|
||||||
|
- The runner's `setStatus` calls assume the option names listed above. Don't rename or translate them.
|
||||||
95
src/cli.ts
Normal file
95
src/cli.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { graphql } from "@octokit/graphql";
|
||||||
|
import { Command } from "commander";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { AgentRunner } from "./modules/agent-runner.js";
|
||||||
|
import { BranchManager } from "./modules/branch-manager.js";
|
||||||
|
import { LoopOrchestrator } from "./modules/loop-orchestrator.js";
|
||||||
|
import { ProjectStateClient } from "./modules/project-state-client.js";
|
||||||
|
import type { Variant } from "./types.js";
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name("agentic-toolkit")
|
||||||
|
.description("Domna agentic toolkit — sandcastle-driven runner")
|
||||||
|
.version("0.0.1");
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("run")
|
||||||
|
.description("Execute a GitHub Project of issues against a target repo.")
|
||||||
|
.requiredOption("--project <number>", "GitHub Project (v2) number", parseIntStrict)
|
||||||
|
.requiredOption(
|
||||||
|
"--mode <variant>",
|
||||||
|
"Execution mode: per-ticket | single-pr",
|
||||||
|
parseVariant,
|
||||||
|
)
|
||||||
|
.requiredOption("--owner <login>", "Owner login of the project repo")
|
||||||
|
.requiredOption("--repo <name>", "Repo name that owns the project")
|
||||||
|
.option(
|
||||||
|
"--target-repo <path>",
|
||||||
|
"Path to the target repo to operate on. Defaults to cwd.",
|
||||||
|
process.cwd(),
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--base-branch <ref>",
|
||||||
|
"Branch to base new work on. Defaults to current HEAD of the target repo.",
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
const githubToken = process.env.GITHUB_TOKEN;
|
||||||
|
if (!githubToken) {
|
||||||
|
console.error(
|
||||||
|
"Set GITHUB_TOKEN in the environment (a PAT with repo + project scopes).",
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRepoPath = resolve(opts.targetRepo);
|
||||||
|
const authedGraphql = graphql.defaults({
|
||||||
|
headers: { authorization: `token ${githubToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const stateClient = new ProjectStateClient(
|
||||||
|
authedGraphql,
|
||||||
|
opts.owner,
|
||||||
|
opts.repo,
|
||||||
|
);
|
||||||
|
const branchManager = new BranchManager({ targetRepoPath });
|
||||||
|
const agentRunner = new AgentRunner({ targetRepoPath });
|
||||||
|
|
||||||
|
const orchestrator = new LoopOrchestrator(
|
||||||
|
{
|
||||||
|
projectNumber: opts.project,
|
||||||
|
variant: opts.mode,
|
||||||
|
targetRepoPath,
|
||||||
|
baseBranch: opts.baseBranch,
|
||||||
|
githubToken,
|
||||||
|
ownerLogin: opts.owner,
|
||||||
|
repo: opts.repo,
|
||||||
|
},
|
||||||
|
{ stateClient, agentRunner, branchManager },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outcome = await orchestrator.run();
|
||||||
|
console.log(outcome.message);
|
||||||
|
if (outcome.kind === "halted") process.exit(1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseIntStrict(value: string): number {
|
||||||
|
const n = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(n)) throw new Error(`Not a number: ${value}`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVariant(value: string): Variant {
|
||||||
|
if (value !== "per-ticket" && value !== "single-pr") {
|
||||||
|
throw new Error(`Mode must be 'per-ticket' or 'single-pr', got: ${value}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
program.parseAsync(process.argv);
|
||||||
91
src/modules/agent-runner.ts
Normal file
91
src/modules/agent-runner.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { run as sandcastleRun, type RunResult } from "@ai-hero/sandcastle";
|
||||||
|
import { claudeCode } from "@ai-hero/sandcastle";
|
||||||
|
import docker from "@ai-hero/sandcastle/sandboxes/docker";
|
||||||
|
import type { AgentResult, FailureKind } from "../types.js";
|
||||||
|
|
||||||
|
export interface AgentRunnerOptions {
|
||||||
|
targetRepoPath: string;
|
||||||
|
imageName?: string;
|
||||||
|
model?: string;
|
||||||
|
idleTimeoutSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_IMAGE = "sandcastle:agentic-toolkit";
|
||||||
|
const DEFAULT_MODEL = "claude-opus-4-7";
|
||||||
|
const DEFAULT_IDLE_TIMEOUT = 900;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps `sandcastle.run()` with Domna's defaults: Docker provider, claudeCode
|
||||||
|
* agent, named-branch strategy. The branch is created on top of `baseBranch`
|
||||||
|
* if it doesn't exist; otherwise commits land on top of the existing branch
|
||||||
|
* (single-pr variant reuses one branch across calls).
|
||||||
|
*/
|
||||||
|
export class AgentRunner {
|
||||||
|
constructor(private readonly opts: AgentRunnerOptions) {}
|
||||||
|
|
||||||
|
async runTicket(args: {
|
||||||
|
prompt: string;
|
||||||
|
branch: string;
|
||||||
|
baseBranch: string;
|
||||||
|
}): Promise<AgentResult & { result?: RunResult }> {
|
||||||
|
try {
|
||||||
|
const result = await sandcastleRun({
|
||||||
|
agent: claudeCode(this.opts.model ?? DEFAULT_MODEL),
|
||||||
|
sandbox: docker({
|
||||||
|
imageName: this.opts.imageName ?? DEFAULT_IMAGE,
|
||||||
|
}),
|
||||||
|
cwd: this.opts.targetRepoPath,
|
||||||
|
prompt: args.prompt,
|
||||||
|
branchStrategy: {
|
||||||
|
type: "branch",
|
||||||
|
branch: args.branch,
|
||||||
|
baseBranch: args.baseBranch,
|
||||||
|
},
|
||||||
|
idleTimeoutSeconds:
|
||||||
|
this.opts.idleTimeoutSeconds ?? DEFAULT_IDLE_TIMEOUT,
|
||||||
|
logging: { type: "stdout" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.commits.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
failure: {
|
||||||
|
kind: "agent-error",
|
||||||
|
logs: "Agent produced no commits.",
|
||||||
|
},
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, result };
|
||||||
|
} catch (err) {
|
||||||
|
const kind = classifyFailure(err);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
failure: {
|
||||||
|
kind,
|
||||||
|
logs: err instanceof Error ? err.stack ?? err.message : String(err),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyFailure(err: unknown): FailureKind {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
const msg = err.message.toLowerCase();
|
||||||
|
if (msg.includes("timeout") || msg.includes("timed out")) {
|
||||||
|
return "sandbox-timeout";
|
||||||
|
}
|
||||||
|
if (msg.includes("test") && (msg.includes("fail") || msg.includes("red"))) {
|
||||||
|
return "tests-failed";
|
||||||
|
}
|
||||||
|
if (msg.includes("build")) {
|
||||||
|
return "build-failed";
|
||||||
|
}
|
||||||
|
if (msg.includes("agent")) {
|
||||||
|
return "agent-error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
45
src/modules/branch-manager.test.ts
Normal file
45
src/modules/branch-manager.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ProjectIssue } from "../types.js";
|
||||||
|
import { branchNameFor, slugify } from "./branch-manager.js";
|
||||||
|
|
||||||
|
const issue = (n: number, title: string): ProjectIssue => ({
|
||||||
|
number: n,
|
||||||
|
nodeId: `node-${n}`,
|
||||||
|
title,
|
||||||
|
body: "",
|
||||||
|
kind: "AFK",
|
||||||
|
status: "Ready",
|
||||||
|
blockedBy: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("slugify", () => {
|
||||||
|
it("lowercases and replaces non-alphanumerics with single dashes", () => {
|
||||||
|
expect(slugify("Wire up Assessment Export!")).toBe(
|
||||||
|
"wire-up-assessment-export",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims leading/trailing dashes", () => {
|
||||||
|
expect(slugify("---hello---")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps length at 50 characters", () => {
|
||||||
|
const long = "a".repeat(80);
|
||||||
|
expect(slugify(long).length).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("branchNameFor", () => {
|
||||||
|
it("returns one branch per project for single-pr", () => {
|
||||||
|
const a = branchNameFor("single-pr", "auth-rewrite", issue(1, "First"));
|
||||||
|
const b = branchNameFor("single-pr", "auth-rewrite", issue(2, "Second"));
|
||||||
|
expect(a).toBe("claude/auth-rewrite");
|
||||||
|
expect(b).toBe("claude/auth-rewrite");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns per-issue branches for per-ticket", () => {
|
||||||
|
expect(
|
||||||
|
branchNameFor("per-ticket", "auth", issue(42, "Wire up Auth Middleware")),
|
||||||
|
).toBe("claude/auth/42-wire-up-auth-middleware");
|
||||||
|
});
|
||||||
|
});
|
||||||
102
src/modules/branch-manager.ts
Normal file
102
src/modules/branch-manager.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { execa } from "execa";
|
||||||
|
import type { ProjectIssue, Variant } from "../types.js";
|
||||||
|
|
||||||
|
const slugRegex = /[^a-z0-9]+/g;
|
||||||
|
|
||||||
|
export function slugify(title: string): string {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(slugRegex, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function branchNameFor(
|
||||||
|
variant: Variant,
|
||||||
|
projectSlug: string,
|
||||||
|
issue: ProjectIssue,
|
||||||
|
): string {
|
||||||
|
if (variant === "single-pr") {
|
||||||
|
return `claude/${projectSlug}`;
|
||||||
|
}
|
||||||
|
return `claude/${projectSlug}/${issue.number}-${slugify(issue.title)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchManagerOptions {
|
||||||
|
targetRepoPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper around git + gh in the target repo. Sandcastle handles branch
|
||||||
|
* creation and commit merge-back via its branch strategy, so this module
|
||||||
|
* exists for the host-side operations the runner needs around it: detect the
|
||||||
|
* launch HEAD (used as baseBranch), push, and open PRs.
|
||||||
|
*/
|
||||||
|
export class BranchManager {
|
||||||
|
constructor(private readonly opts: BranchManagerOptions) {}
|
||||||
|
|
||||||
|
private async run(
|
||||||
|
bin: string,
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return execa(bin, args, { cwd: this.opts.targetRepoPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectCurrentBranch(): Promise<string> {
|
||||||
|
const { stdout } = await this.run("git", [
|
||||||
|
"rev-parse",
|
||||||
|
"--abbrev-ref",
|
||||||
|
"HEAD",
|
||||||
|
]);
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasLocalBranch(branch: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.run("git", [
|
||||||
|
"show-ref",
|
||||||
|
"--verify",
|
||||||
|
"--quiet",
|
||||||
|
`refs/heads/${branch}`,
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async push(branch: string): Promise<void> {
|
||||||
|
await this.run("git", ["push", "-u", "origin", branch]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async commitsBetween(base: string, branch: string): Promise<number> {
|
||||||
|
const { stdout } = await this.run("git", [
|
||||||
|
"rev-list",
|
||||||
|
"--count",
|
||||||
|
`${base}..${branch}`,
|
||||||
|
]);
|
||||||
|
return Number.parseInt(stdout.trim(), 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async openPullRequest(opts: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
base: string;
|
||||||
|
head: string;
|
||||||
|
}): Promise<{ url: string }> {
|
||||||
|
const { stdout } = await this.run("gh", [
|
||||||
|
"pr",
|
||||||
|
"create",
|
||||||
|
"--title",
|
||||||
|
opts.title,
|
||||||
|
"--body",
|
||||||
|
opts.body,
|
||||||
|
"--base",
|
||||||
|
opts.base,
|
||||||
|
"--head",
|
||||||
|
opts.head,
|
||||||
|
]);
|
||||||
|
const url = stdout.trim().split("\n").pop() ?? "";
|
||||||
|
return { url };
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/modules/failure-handler.test.ts
Normal file
60
src/modules/failure-handler.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { FailureKind, Variant } from "../types.js";
|
||||||
|
import { decide } from "./failure-handler.js";
|
||||||
|
|
||||||
|
const FAILURES: FailureKind[] = [
|
||||||
|
"agent-error",
|
||||||
|
"tests-failed",
|
||||||
|
"build-failed",
|
||||||
|
"sandbox-timeout",
|
||||||
|
"unknown",
|
||||||
|
];
|
||||||
|
const VARIANTS: Variant[] = ["per-ticket", "single-pr"];
|
||||||
|
|
||||||
|
describe("decide", () => {
|
||||||
|
describe("first failure", () => {
|
||||||
|
for (const variant of VARIANTS) {
|
||||||
|
for (const failure of FAILURES) {
|
||||||
|
it(`retries on first failure (variant=${variant}, kind=${failure})`, () => {
|
||||||
|
const action = decide(failure, { variant, retryCount: 0 });
|
||||||
|
expect(action.kind).toBe("retry");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("after retry", () => {
|
||||||
|
for (const failure of FAILURES) {
|
||||||
|
it(`per-ticket variant skips after second failure (kind=${failure})`, () => {
|
||||||
|
const action = decide(failure, {
|
||||||
|
variant: "per-ticket",
|
||||||
|
retryCount: 1,
|
||||||
|
});
|
||||||
|
expect(action.kind).toBe("skip");
|
||||||
|
if (action.kind === "skip") {
|
||||||
|
expect(action.reason).toContain(failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`single-pr variant halts after second failure (kind=${failure})`, () => {
|
||||||
|
const action = decide(failure, {
|
||||||
|
variant: "single-pr",
|
||||||
|
retryCount: 1,
|
||||||
|
});
|
||||||
|
expect(action.kind).toBe("halt");
|
||||||
|
if (action.kind === "halt") {
|
||||||
|
expect(action.reason).toContain(failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("halts/skips on third+ failure too (defensive)", () => {
|
||||||
|
expect(
|
||||||
|
decide("unknown", { variant: "per-ticket", retryCount: 2 }).kind,
|
||||||
|
).toBe("skip");
|
||||||
|
expect(decide("unknown", { variant: "single-pr", retryCount: 5 }).kind).toBe(
|
||||||
|
"halt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
src/modules/failure-handler.ts
Normal file
30
src/modules/failure-handler.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { FailureAction, FailureContext, FailureKind } from "../types.js";
|
||||||
|
|
||||||
|
const MAX_RETRIES = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what to do when a ticket execution fails. Pure state machine.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - First failure (any kind, any variant): retry once.
|
||||||
|
* - Second failure on per-ticket variant: skip this ticket, continue with peers
|
||||||
|
* in the same phase. The phase gate naturally blocks advance until the human
|
||||||
|
* resolves the failed issue.
|
||||||
|
* - Second failure on single-pr variant: halt. The shared branch is corrupted
|
||||||
|
* and continuing would compound the damage.
|
||||||
|
*/
|
||||||
|
export function decide(
|
||||||
|
failure: FailureKind,
|
||||||
|
ctx: FailureContext,
|
||||||
|
): FailureAction {
|
||||||
|
if (ctx.retryCount < MAX_RETRIES) {
|
||||||
|
return { kind: "retry" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = `Ticket failed after ${ctx.retryCount + 1} attempt(s) with: ${failure}`;
|
||||||
|
|
||||||
|
if (ctx.variant === "per-ticket") {
|
||||||
|
return { kind: "skip", reason };
|
||||||
|
}
|
||||||
|
return { kind: "halt", reason };
|
||||||
|
}
|
||||||
389
src/modules/loop-orchestrator.ts
Normal file
389
src/modules/loop-orchestrator.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type {
|
||||||
|
IssueStatus,
|
||||||
|
Phase,
|
||||||
|
ProjectIssue,
|
||||||
|
ProjectState,
|
||||||
|
RepoContext,
|
||||||
|
RunnerOptions,
|
||||||
|
Variant,
|
||||||
|
} from "../types.js";
|
||||||
|
import type { AgentRunner } from "./agent-runner.js";
|
||||||
|
import { BranchManager, branchNameFor } from "./branch-manager.js";
|
||||||
|
import { decide } from "./failure-handler.js";
|
||||||
|
import { currentPhase, schedule } from "./phase-scheduler.js";
|
||||||
|
import {
|
||||||
|
type ProjectFieldRefs,
|
||||||
|
type ProjectStateClient,
|
||||||
|
} from "./project-state-client.js";
|
||||||
|
import { build as buildPrompt } from "./prompt-builder.js";
|
||||||
|
|
||||||
|
export interface LoopDeps {
|
||||||
|
stateClient: ProjectStateClient;
|
||||||
|
agentRunner: AgentRunner;
|
||||||
|
branchManager: BranchManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoopOutcome {
|
||||||
|
kind:
|
||||||
|
| "phase-complete"
|
||||||
|
| "all-done"
|
||||||
|
| "halted"
|
||||||
|
| "halted-no-ready-issues";
|
||||||
|
message: string;
|
||||||
|
prsOpened: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECT_SLUG_FALLBACK = "project";
|
||||||
|
|
||||||
|
export class LoopOrchestrator {
|
||||||
|
constructor(
|
||||||
|
private readonly opts: RunnerOptions,
|
||||||
|
private readonly deps: LoopDeps,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async run(): Promise<LoopOutcome> {
|
||||||
|
const baseBranch =
|
||||||
|
this.opts.baseBranch ??
|
||||||
|
(await this.deps.branchManager.detectCurrentBranch());
|
||||||
|
|
||||||
|
const { state, refs } =
|
||||||
|
await this.deps.stateClient.readProjectState(this.opts.projectNumber);
|
||||||
|
|
||||||
|
const projectSlug = projectSlugFrom(state) ?? PROJECT_SLUG_FALLBACK;
|
||||||
|
const repoContext = detectRepoContext(this.opts.targetRepoPath);
|
||||||
|
|
||||||
|
let workingState = await this.autopromoteAfk(state, refs);
|
||||||
|
|
||||||
|
const phases = schedule(workingState.issues);
|
||||||
|
const phase = currentPhase(phases);
|
||||||
|
if (!phase) {
|
||||||
|
return {
|
||||||
|
kind: "all-done",
|
||||||
|
message: "All issues are Done. Nothing to do.",
|
||||||
|
prsOpened: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prsOpened: string[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
workingState = await this.refresh(refs);
|
||||||
|
const refreshedPhases = schedule(workingState.issues);
|
||||||
|
const refreshedPhase = currentPhase(refreshedPhases);
|
||||||
|
|
||||||
|
if (!refreshedPhase) {
|
||||||
|
return {
|
||||||
|
kind: "all-done",
|
||||||
|
message: "All issues are Done.",
|
||||||
|
prsOpened,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (refreshedPhase.index !== phase.index) {
|
||||||
|
return {
|
||||||
|
kind: "phase-complete",
|
||||||
|
message: phaseCompleteMessage(phase, prsOpened),
|
||||||
|
prsOpened,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = pickNextReady(refreshedPhase);
|
||||||
|
if (!next) {
|
||||||
|
if (this.opts.variant === "single-pr") {
|
||||||
|
return await this.finaliseSinglePr(
|
||||||
|
workingState,
|
||||||
|
phase,
|
||||||
|
projectSlug,
|
||||||
|
baseBranch,
|
||||||
|
prsOpened,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "phase-complete",
|
||||||
|
message: phaseCompleteMessage(phase, prsOpened),
|
||||||
|
prsOpened,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.executeTicket({
|
||||||
|
issue: next,
|
||||||
|
refs,
|
||||||
|
repoContext,
|
||||||
|
projectSlug,
|
||||||
|
baseBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.kind === "halt") {
|
||||||
|
return {
|
||||||
|
kind: "halted",
|
||||||
|
message: result.reason,
|
||||||
|
prsOpened,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result.kind === "skipped") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (result.kind === "merged-into-shared-branch") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (result.kind === "pr-opened" && result.prUrl) {
|
||||||
|
prsOpened.push(result.prUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finaliseSinglePr(
|
||||||
|
state: ProjectState,
|
||||||
|
phase: Phase,
|
||||||
|
projectSlug: string,
|
||||||
|
baseBranch: string,
|
||||||
|
prsOpened: string[],
|
||||||
|
): Promise<LoopOutcome> {
|
||||||
|
const branch = branchNameFor("single-pr", projectSlug, phase.issues[0]!);
|
||||||
|
const commits = await this.deps.branchManager
|
||||||
|
.commitsBetween(baseBranch, branch)
|
||||||
|
.catch(() => 0);
|
||||||
|
if (commits === 0) {
|
||||||
|
return {
|
||||||
|
kind: "halted-no-ready-issues",
|
||||||
|
message: "No commits to PR. Nothing was implemented.",
|
||||||
|
prsOpened,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.deps.branchManager.push(branch);
|
||||||
|
const issuesInPR = state.issues.filter((i) => i.status !== "Done");
|
||||||
|
const pr = await this.deps.branchManager.openPullRequest({
|
||||||
|
title: `Implement project #${state.projectNumber}`,
|
||||||
|
body: singlePrBody(state, issuesInPR),
|
||||||
|
base: baseBranch,
|
||||||
|
head: branch,
|
||||||
|
});
|
||||||
|
for (const issue of issuesInPR) {
|
||||||
|
await this.setStatus(issue, "In review");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "all-done",
|
||||||
|
message: `Single PR opened: ${pr.url}`,
|
||||||
|
prsOpened: [...prsOpened, pr.url],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeTicket(args: {
|
||||||
|
issue: ProjectIssue;
|
||||||
|
refs: ProjectFieldRefs;
|
||||||
|
repoContext: RepoContext;
|
||||||
|
projectSlug: string;
|
||||||
|
baseBranch: string;
|
||||||
|
}): Promise<
|
||||||
|
| { kind: "pr-opened"; prUrl: string }
|
||||||
|
| { kind: "merged-into-shared-branch" }
|
||||||
|
| { kind: "skipped" }
|
||||||
|
| { kind: "halt"; reason: string }
|
||||||
|
> {
|
||||||
|
const { issue, refs, repoContext, projectSlug, baseBranch } = args;
|
||||||
|
|
||||||
|
await this.claim(issue);
|
||||||
|
|
||||||
|
const branch = branchNameFor(this.opts.variant, projectSlug, issue);
|
||||||
|
const prompt = buildPrompt({ issue, repoContext });
|
||||||
|
|
||||||
|
let retryCount = 0;
|
||||||
|
while (true) {
|
||||||
|
const result = await this.deps.agentRunner.runTicket({
|
||||||
|
prompt,
|
||||||
|
branch,
|
||||||
|
baseBranch,
|
||||||
|
});
|
||||||
|
if (result.success) break;
|
||||||
|
|
||||||
|
const action = decide(result.failure!.kind, {
|
||||||
|
variant: this.opts.variant,
|
||||||
|
retryCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action.kind === "retry") {
|
||||||
|
retryCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (action.kind === "skip") {
|
||||||
|
await this.deps.stateClient.postIssueComment(
|
||||||
|
issue.nodeId,
|
||||||
|
failureCommentBody(action.reason, result.failure!.logs),
|
||||||
|
);
|
||||||
|
await this.setStatus(issue, "Needs human", refs);
|
||||||
|
return { kind: "skipped" };
|
||||||
|
}
|
||||||
|
// halt
|
||||||
|
await this.deps.stateClient.postIssueComment(
|
||||||
|
issue.nodeId,
|
||||||
|
failureCommentBody(action.reason, result.failure!.logs),
|
||||||
|
);
|
||||||
|
await this.setStatus(issue, "Needs human", refs);
|
||||||
|
return { kind: "halt", reason: action.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.opts.variant === "single-pr") {
|
||||||
|
return { kind: "merged-into-shared-branch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.deps.branchManager.push(branch);
|
||||||
|
const pr = await this.deps.branchManager.openPullRequest({
|
||||||
|
title: `${issue.title} (#${issue.number})`,
|
||||||
|
body: perTicketPrBody(issue),
|
||||||
|
base: baseBranch,
|
||||||
|
head: branch,
|
||||||
|
});
|
||||||
|
await this.setStatus(issue, "In review", refs);
|
||||||
|
return { kind: "pr-opened", prUrl: pr.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async claim(issue: ProjectIssue): Promise<void> {
|
||||||
|
const refs = await this.deps.stateClient
|
||||||
|
.readProjectState(this.opts.projectNumber)
|
||||||
|
.then((r) => r.refs);
|
||||||
|
await this.setStatus(issue, "In progress", refs);
|
||||||
|
const me = await this.detectViewer();
|
||||||
|
if (me) {
|
||||||
|
await this.deps.stateClient.assignIssue(issue.nodeId, me).catch(() => {
|
||||||
|
/* assignment is best-effort */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectViewer(): Promise<string | undefined> {
|
||||||
|
return process.env.GITHUB_VIEWER_LOGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refresh(_refs: ProjectFieldRefs): Promise<ProjectState> {
|
||||||
|
const { state, refs } = await this.deps.stateClient.readProjectState(
|
||||||
|
this.opts.projectNumber,
|
||||||
|
);
|
||||||
|
return await this.autopromoteAfk(state, refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async autopromoteAfk(
|
||||||
|
state: ProjectState,
|
||||||
|
refs: ProjectFieldRefs,
|
||||||
|
): Promise<ProjectState> {
|
||||||
|
const doneNumbers = new Set(
|
||||||
|
state.issues.filter((i) => i.status === "Done").map((i) => i.number),
|
||||||
|
);
|
||||||
|
const updates: ProjectIssue[] = [];
|
||||||
|
for (const issue of state.issues) {
|
||||||
|
const blockersResolved = issue.blockedBy.every(
|
||||||
|
(b) =>
|
||||||
|
doneNumbers.has(b) ||
|
||||||
|
!state.issues.find((i) => i.number === b),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
issue.kind === "AFK" &&
|
||||||
|
issue.status === "Backlog" &&
|
||||||
|
blockersResolved
|
||||||
|
) {
|
||||||
|
await this.setStatus(issue, "Ready", refs);
|
||||||
|
updates.push({ ...issue, status: "Ready" });
|
||||||
|
} else {
|
||||||
|
updates.push(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...state, issues: updates };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setStatus(
|
||||||
|
issue: ProjectIssue,
|
||||||
|
status: IssueStatus,
|
||||||
|
refs?: ProjectFieldRefs,
|
||||||
|
): Promise<void> {
|
||||||
|
const r =
|
||||||
|
refs ??
|
||||||
|
(await this.deps.stateClient.readProjectState(this.opts.projectNumber))
|
||||||
|
.refs;
|
||||||
|
const itemId = await this.findItemIdForIssue(issue);
|
||||||
|
if (!itemId) return;
|
||||||
|
const optionId = r.statusOptionIds[status];
|
||||||
|
await this.deps.stateClient.setStatus(
|
||||||
|
r.projectId,
|
||||||
|
itemId,
|
||||||
|
r.statusFieldId,
|
||||||
|
optionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findItemIdForIssue(
|
||||||
|
issue: ProjectIssue,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
return issue.itemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNextReady(phase: Phase): ProjectIssue | undefined {
|
||||||
|
return phase.issues
|
||||||
|
.filter((i) => i.status === "Ready" && i.kind === "AFK")
|
||||||
|
.sort((a, b) => a.number - b.number)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectSlugFrom(state: ProjectState): string | undefined {
|
||||||
|
return `${state.repo}-p${state.projectNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectRepoContext(targetRepoPath: string): RepoContext {
|
||||||
|
const ctx: RepoContext = {};
|
||||||
|
if (existsSync(join(targetRepoPath, "CONTEXT.md"))) {
|
||||||
|
ctx.contextMdPath = "CONTEXT.md";
|
||||||
|
}
|
||||||
|
if (existsSync(join(targetRepoPath, "docs", "adr"))) {
|
||||||
|
ctx.adrDirPath = "docs/adr";
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureCommentBody(reason: string, logs: string): string {
|
||||||
|
const trimmed = logs.length > 6000 ? logs.slice(-6000) : logs;
|
||||||
|
return [
|
||||||
|
`### Automated run failed`,
|
||||||
|
"",
|
||||||
|
reason,
|
||||||
|
"",
|
||||||
|
"<details><summary>Logs (last 6k chars)</summary>",
|
||||||
|
"",
|
||||||
|
"```",
|
||||||
|
trimmed,
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"</details>",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function perTicketPrBody(issue: ProjectIssue): string {
|
||||||
|
return [
|
||||||
|
`Closes #${issue.number}`,
|
||||||
|
"",
|
||||||
|
"Implemented by Domna agentic-toolkit.",
|
||||||
|
"",
|
||||||
|
"Review: confirm acceptance criteria satisfied; rebase on top of `main` if conflicts.",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function singlePrBody(state: ProjectState, issues: ProjectIssue[]): string {
|
||||||
|
const closes = issues.map((i) => `Closes #${i.number}`).join("\n");
|
||||||
|
return [
|
||||||
|
`Bundled implementation for project #${state.projectNumber}.`,
|
||||||
|
"",
|
||||||
|
closes,
|
||||||
|
"",
|
||||||
|
"Implemented by Domna agentic-toolkit (single-pr mode).",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseCompleteMessage(phase: Phase, prs: string[]): string {
|
||||||
|
const list = prs.length === 0 ? "(no new PRs)" : prs.map((u) => `- ${u}`).join("\n");
|
||||||
|
return [
|
||||||
|
`Phase ${phase.index} complete. Review and merge the PRs below before re-running.`,
|
||||||
|
"",
|
||||||
|
list,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Variant };
|
||||||
122
src/modules/phase-scheduler.test.ts
Normal file
122
src/modules/phase-scheduler.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ProjectIssue } from "../types.js";
|
||||||
|
import { currentPhase, schedule } from "./phase-scheduler.js";
|
||||||
|
|
||||||
|
const issue = (
|
||||||
|
n: number,
|
||||||
|
blockedBy: number[] = [],
|
||||||
|
overrides: Partial<ProjectIssue> = {},
|
||||||
|
): ProjectIssue => ({
|
||||||
|
number: n,
|
||||||
|
nodeId: `node-${n}`,
|
||||||
|
title: `Issue ${n}`,
|
||||||
|
body: "",
|
||||||
|
kind: "AFK",
|
||||||
|
status: "Ready",
|
||||||
|
blockedBy,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("schedule", () => {
|
||||||
|
it("puts unblocked issues in phase 0", () => {
|
||||||
|
const phases = schedule([issue(1), issue(2), issue(3)]);
|
||||||
|
expect(phases).toHaveLength(1);
|
||||||
|
expect(phases[0]?.issues.map((i) => i.number)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders a linear chain into one phase per node", () => {
|
||||||
|
const phases = schedule([
|
||||||
|
issue(1),
|
||||||
|
issue(2, [1]),
|
||||||
|
issue(3, [2]),
|
||||||
|
]);
|
||||||
|
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
|
||||||
|
[1],
|
||||||
|
[2],
|
||||||
|
[3],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups fan-out children into the same phase", () => {
|
||||||
|
const phases = schedule([
|
||||||
|
issue(1),
|
||||||
|
issue(2, [1]),
|
||||||
|
issue(3, [1]),
|
||||||
|
issue(4, [1]),
|
||||||
|
]);
|
||||||
|
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
|
||||||
|
[1],
|
||||||
|
[2, 3, 4],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a diamond DAG", () => {
|
||||||
|
const phases = schedule([
|
||||||
|
issue(1),
|
||||||
|
issue(2, [1]),
|
||||||
|
issue(3, [1]),
|
||||||
|
issue(4, [2, 3]),
|
||||||
|
]);
|
||||||
|
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
|
||||||
|
[1],
|
||||||
|
[2, 3],
|
||||||
|
[4],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats blockers outside the input set as satisfied", () => {
|
||||||
|
const phases = schedule([issue(2, [99]), issue(3, [2])]);
|
||||||
|
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
|
||||||
|
[2],
|
||||||
|
[3],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes Done issues from phases but keeps their blockers satisfied", () => {
|
||||||
|
const phases = schedule([
|
||||||
|
issue(1, [], { status: "Done" }),
|
||||||
|
issue(2, [1]),
|
||||||
|
issue(3, [2]),
|
||||||
|
]);
|
||||||
|
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
|
||||||
|
[2],
|
||||||
|
[3],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("partitions disconnected components in parallel", () => {
|
||||||
|
const phases = schedule([
|
||||||
|
issue(1),
|
||||||
|
issue(2, [1]),
|
||||||
|
issue(10),
|
||||||
|
issue(11, [10]),
|
||||||
|
]);
|
||||||
|
expect(phases.map((p) => p.issues.map((i) => i.number))).toEqual([
|
||||||
|
[1, 10],
|
||||||
|
[2, 11],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a cycle", () => {
|
||||||
|
expect(() => schedule([issue(1, [2]), issue(2, [1])])).toThrow(/Cycle/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("currentPhase", () => {
|
||||||
|
it("returns the first phase containing any non-Done issue", () => {
|
||||||
|
const phases = schedule([
|
||||||
|
issue(1, [], { status: "Done" }),
|
||||||
|
issue(2, [1], { status: "In progress" }),
|
||||||
|
issue(3, [2]),
|
||||||
|
]);
|
||||||
|
expect(currentPhase(phases)?.issues.map((i) => i.number)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when all phases are Done", () => {
|
||||||
|
const phases = schedule([
|
||||||
|
issue(1, [], { status: "Done" }),
|
||||||
|
issue(2, [1], { status: "Done" }),
|
||||||
|
]);
|
||||||
|
expect(currentPhase(phases)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
84
src/modules/phase-scheduler.ts
Normal file
84
src/modules/phase-scheduler.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import type { Phase, ProjectIssue } from "../types.js";
|
||||||
|
|
||||||
|
export interface ScheduleOptions {
|
||||||
|
/**
|
||||||
|
* If true, issues already `Done` are excluded from phases entirely (their
|
||||||
|
* blockers are still considered satisfied for downstream issues). Default true.
|
||||||
|
*/
|
||||||
|
excludeDone?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topologically partition the issue set into phases.
|
||||||
|
*
|
||||||
|
* Phase 0 = issues with no remaining blockers (after `Done` issues are
|
||||||
|
* resolved). Phase N = issues whose every blocker is in some phase < N.
|
||||||
|
*
|
||||||
|
* Issues referenced by `blockedBy` that don't exist in the input set are
|
||||||
|
* treated as already satisfied (they're outside this project's scope).
|
||||||
|
*
|
||||||
|
* Throws if the graph contains a cycle.
|
||||||
|
*/
|
||||||
|
export function schedule(
|
||||||
|
issues: ProjectIssue[],
|
||||||
|
options: ScheduleOptions = {},
|
||||||
|
): Phase[] {
|
||||||
|
const { excludeDone = true } = options;
|
||||||
|
const byNumber = new Map<number, ProjectIssue>();
|
||||||
|
for (const i of issues) byNumber.set(i.number, i);
|
||||||
|
|
||||||
|
const remaining = new Map<number, ProjectIssue>();
|
||||||
|
for (const i of issues) {
|
||||||
|
if (excludeDone && i.status === "Done") continue;
|
||||||
|
remaining.set(i.number, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseOf = new Map<number, number>();
|
||||||
|
for (const i of issues) {
|
||||||
|
if (i.status === "Done") phaseOf.set(i.number, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phases: Phase[] = [];
|
||||||
|
let phaseIndex = 0;
|
||||||
|
|
||||||
|
while (remaining.size > 0) {
|
||||||
|
const ready: ProjectIssue[] = [];
|
||||||
|
for (const issue of remaining.values()) {
|
||||||
|
const allBlockersResolved = issue.blockedBy.every((b) => {
|
||||||
|
const blocker = byNumber.get(b);
|
||||||
|
if (!blocker) return true;
|
||||||
|
const p = phaseOf.get(b);
|
||||||
|
return p !== undefined && p < phaseIndex;
|
||||||
|
});
|
||||||
|
if (allBlockersResolved) ready.push(issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ready.length === 0) {
|
||||||
|
const stuck = [...remaining.keys()].join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`Cycle detected or unresolvable blocker chain among issues: ${stuck}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ready.sort((a, b) => a.number - b.number);
|
||||||
|
phases.push({ index: phaseIndex, issues: ready });
|
||||||
|
for (const issue of ready) {
|
||||||
|
phaseOf.set(issue.number, phaseIndex);
|
||||||
|
remaining.delete(issue.number);
|
||||||
|
}
|
||||||
|
phaseIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return phases;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "current" phase is the lowest-indexed phase that still has any
|
||||||
|
* non-`Done` work left. Returns undefined if every phase is fully complete.
|
||||||
|
*/
|
||||||
|
export function currentPhase(phases: Phase[]): Phase | undefined {
|
||||||
|
for (const phase of phases) {
|
||||||
|
if (phase.issues.some((i) => i.status !== "Done")) return phase;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
180
src/modules/project-state-client.test.ts
Normal file
180
src/modules/project-state-client.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
detectKindFromLabels,
|
||||||
|
parseBlockedByFromBody,
|
||||||
|
parseProjectResponse,
|
||||||
|
} from "./project-state-client.js";
|
||||||
|
|
||||||
|
describe("detectKindFromLabels", () => {
|
||||||
|
it("returns AFK for an empty label set", () => {
|
||||||
|
expect(detectKindFromLabels([])).toBe("AFK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns HITL when a `hitl` label is present", () => {
|
||||||
|
expect(detectKindFromLabels(["bug", "hitl"])).toBe("HITL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises `ready-for-human` as HITL", () => {
|
||||||
|
expect(detectKindFromLabels(["ready-for-human"])).toBe("HITL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
expect(detectKindFromLabels(["HITL"])).toBe("HITL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseBlockedByFromBody", () => {
|
||||||
|
it("returns [] when there is no Blocked by section", () => {
|
||||||
|
expect(parseBlockedByFromBody("## What to build\nDo a thing")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts issue numbers from a Blocked by section", () => {
|
||||||
|
const body = [
|
||||||
|
"## What to build",
|
||||||
|
"stuff",
|
||||||
|
"",
|
||||||
|
"## Blocked by",
|
||||||
|
"- #5",
|
||||||
|
"- #12",
|
||||||
|
"",
|
||||||
|
"## Acceptance criteria",
|
||||||
|
"- [ ] #99 should not count (different section)",
|
||||||
|
].join("\n");
|
||||||
|
expect(parseBlockedByFromBody(body)).toEqual([5, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes and sorts", () => {
|
||||||
|
const body = "## Blocked by\n- #7\n- #3\n- #7\n";
|
||||||
|
expect(parseBlockedByFromBody(body)).toEqual([3, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to whole-body scan when no Blocked by header is present", () => {
|
||||||
|
expect(parseBlockedByFromBody("Refs #42")).toEqual([42]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseProjectResponse", () => {
|
||||||
|
const baseRaw = (overrides: Partial<{ items: any[] }> = {}) => ({
|
||||||
|
repository: {
|
||||||
|
projectV2: {
|
||||||
|
id: "PROJECT_ID",
|
||||||
|
title: "Test",
|
||||||
|
field: {
|
||||||
|
id: "STATUS_FIELD_ID",
|
||||||
|
name: "Status",
|
||||||
|
options: [
|
||||||
|
{ id: "OPT_BACKLOG", name: "Backlog" },
|
||||||
|
{ id: "OPT_READY", name: "Ready" },
|
||||||
|
{ id: "OPT_IN_PROGRESS", name: "In progress" },
|
||||||
|
{ id: "OPT_IN_REVIEW", name: "In review" },
|
||||||
|
{ id: "OPT_NEEDS_HUMAN", name: "Needs human" },
|
||||||
|
{ id: "OPT_DONE", name: "Done" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
items: { nodes: overrides.items ?? [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const issueItem = (n: number, status: string, labels: string[] = []) => ({
|
||||||
|
id: `ITEM_${n}`,
|
||||||
|
fieldValues: {
|
||||||
|
nodes: [{ name: status, field: { name: "Status" } }],
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
id: `ISSUE_${n}`,
|
||||||
|
number: n,
|
||||||
|
title: `Issue ${n}`,
|
||||||
|
body: `body ${n}`,
|
||||||
|
assignees: { nodes: [] },
|
||||||
|
labels: { nodes: labels.map((l) => ({ name: l })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the project is missing", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseProjectResponse(
|
||||||
|
{ repository: { projectV2: null } } as any,
|
||||||
|
"Hestia-Homes",
|
||||||
|
"agentic-toolkit",
|
||||||
|
99,
|
||||||
|
),
|
||||||
|
).toThrow(/not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the Status field is missing", () => {
|
||||||
|
const raw = baseRaw();
|
||||||
|
raw.repository.projectV2.field = null as any;
|
||||||
|
expect(() =>
|
||||||
|
parseProjectResponse(raw as any, "Hestia-Homes", "agentic-toolkit", 1),
|
||||||
|
).toThrow(/missing the required "Status"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a required Status option is missing", () => {
|
||||||
|
const raw = baseRaw();
|
||||||
|
raw.repository.projectV2.field!.options = raw.repository.projectV2.field!.options.filter(
|
||||||
|
(o) => o.name !== "Ready",
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
parseProjectResponse(raw as any, "Hestia-Homes", "agentic-toolkit", 1),
|
||||||
|
).toThrow(/Ready/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps issues with status, labels, and refs", () => {
|
||||||
|
const raw = baseRaw({
|
||||||
|
items: [
|
||||||
|
issueItem(1, "Ready", []),
|
||||||
|
issueItem(2, "Backlog", ["hitl"]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { state, refs } = parseProjectResponse(
|
||||||
|
raw as any,
|
||||||
|
"Hestia-Homes",
|
||||||
|
"agentic-toolkit",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(state.issues).toHaveLength(2);
|
||||||
|
expect(state.issues[0]).toMatchObject({
|
||||||
|
number: 1,
|
||||||
|
kind: "AFK",
|
||||||
|
status: "Ready",
|
||||||
|
});
|
||||||
|
expect(state.issues[1]).toMatchObject({
|
||||||
|
number: 2,
|
||||||
|
kind: "HITL",
|
||||||
|
status: "Backlog",
|
||||||
|
});
|
||||||
|
expect(refs.statusFieldId).toBe("STATUS_FIELD_ID");
|
||||||
|
expect(refs.statusOptionIds.Ready).toBe("OPT_READY");
|
||||||
|
expect(refs.statusOptionIds.Done).toBe("OPT_DONE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to Backlog when an item has no Status field value", () => {
|
||||||
|
const noStatus = issueItem(1, "Backlog");
|
||||||
|
noStatus.fieldValues.nodes = [];
|
||||||
|
const raw = baseRaw({ items: [noStatus] });
|
||||||
|
const { state } = parseProjectResponse(
|
||||||
|
raw as any,
|
||||||
|
"Hestia-Homes",
|
||||||
|
"agentic-toolkit",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(state.issues[0]?.status).toBe("Backlog");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips non-Issue items (PRs, draft items)", () => {
|
||||||
|
const raw = baseRaw({
|
||||||
|
items: [
|
||||||
|
issueItem(1, "Ready"),
|
||||||
|
{ id: "DRAFT", fieldValues: { nodes: [] }, content: {} },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { state } = parseProjectResponse(
|
||||||
|
raw as any,
|
||||||
|
"Hestia-Homes",
|
||||||
|
"agentic-toolkit",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(state.issues).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
315
src/modules/project-state-client.ts
Normal file
315
src/modules/project-state-client.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
import { graphql as defaultGraphql } from "@octokit/graphql";
|
||||||
|
import type {
|
||||||
|
IssueKind,
|
||||||
|
IssueStatus,
|
||||||
|
ProjectIssue,
|
||||||
|
ProjectState,
|
||||||
|
} from "../types.js";
|
||||||
|
|
||||||
|
type GraphqlClient = typeof defaultGraphql;
|
||||||
|
|
||||||
|
const STATUS_FIELD_NAME = "Status";
|
||||||
|
|
||||||
|
const STATUS_NAME_TO_TYPE: Record<string, IssueStatus> = {
|
||||||
|
Backlog: "Backlog",
|
||||||
|
Ready: "Ready",
|
||||||
|
"In progress": "In progress",
|
||||||
|
"In review": "In review",
|
||||||
|
"Needs human": "Needs human",
|
||||||
|
Done: "Done",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROJECT_QUERY = /* GraphQL */ `
|
||||||
|
query ProjectState($owner: String!, $repo: String!, $number: Int!) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
projectV2(number: $number) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
field(name: "Status") {
|
||||||
|
... on ProjectV2SingleSelectField {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
options {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(first: 100) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
fieldValues(first: 20) {
|
||||||
|
nodes {
|
||||||
|
... on ProjectV2ItemFieldSingleSelectValue {
|
||||||
|
name
|
||||||
|
field {
|
||||||
|
... on ProjectV2SingleSelectField {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content {
|
||||||
|
... on Issue {
|
||||||
|
id
|
||||||
|
number
|
||||||
|
title
|
||||||
|
body
|
||||||
|
assignees(first: 5) {
|
||||||
|
nodes {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels(first: 20) {
|
||||||
|
nodes {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface RawProjectResponse {
|
||||||
|
repository: {
|
||||||
|
projectV2: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
field: { id: string; name: string; options: { id: string; name: string }[] } | null;
|
||||||
|
items: { nodes: RawItem[] };
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawItem {
|
||||||
|
id: string;
|
||||||
|
fieldValues: {
|
||||||
|
nodes: Array<{
|
||||||
|
name?: string;
|
||||||
|
field?: { name?: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
content:
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
assignees: { nodes: { login: string }[] };
|
||||||
|
labels: { nodes: { name: string }[] };
|
||||||
|
}
|
||||||
|
| Record<string, never>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectFieldRefs {
|
||||||
|
projectId: string;
|
||||||
|
statusFieldId: string;
|
||||||
|
statusOptionIds: Record<IssueStatus, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectKindFromLabels(labels: string[]): IssueKind {
|
||||||
|
const lower = labels.map((l) => l.toLowerCase());
|
||||||
|
if (lower.includes("hitl")) return "HITL";
|
||||||
|
if (lower.includes("ready-for-human")) return "HITL";
|
||||||
|
return "AFK";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBlockedByFromBody(body: string): number[] {
|
||||||
|
const out = new Set<number>();
|
||||||
|
const blockedSection = body.match(
|
||||||
|
/##\s*Blocked\s*by\s*\n([\s\S]*?)(\n##\s|$)/i,
|
||||||
|
);
|
||||||
|
const haystack = blockedSection?.[1] ?? body;
|
||||||
|
const refRegex = /#(\d+)\b/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = refRegex.exec(haystack)) !== null) {
|
||||||
|
if (m[1]) out.add(Number.parseInt(m[1], 10));
|
||||||
|
}
|
||||||
|
return [...out].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseProjectResponse(
|
||||||
|
raw: RawProjectResponse,
|
||||||
|
ownerLogin: string,
|
||||||
|
repo: string,
|
||||||
|
projectNumber: number,
|
||||||
|
): { state: ProjectState; refs: ProjectFieldRefs } {
|
||||||
|
const project = raw.repository.projectV2;
|
||||||
|
if (!project) throw new Error(`Project #${projectNumber} not found in ${ownerLogin}/${repo}`);
|
||||||
|
|
||||||
|
const statusField = project.field;
|
||||||
|
if (!statusField) {
|
||||||
|
throw new Error(
|
||||||
|
`Project #${projectNumber} is missing the required "${STATUS_FIELD_NAME}" single-select field. Run /to-project to scaffold it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptionIds: Partial<Record<IssueStatus, string>> = {};
|
||||||
|
for (const opt of statusField.options) {
|
||||||
|
const mapped = STATUS_NAME_TO_TYPE[opt.name];
|
||||||
|
if (mapped) statusOptionIds[mapped] = opt.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const required: IssueStatus[] = [
|
||||||
|
"Backlog",
|
||||||
|
"Ready",
|
||||||
|
"In progress",
|
||||||
|
"In review",
|
||||||
|
"Needs human",
|
||||||
|
"Done",
|
||||||
|
];
|
||||||
|
const missing = required.filter((s) => !statusOptionIds[s]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Project #${projectNumber} Status field is missing options: ${missing.join(", ")}. Run /to-project to scaffold them.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues: ProjectIssue[] = [];
|
||||||
|
for (const item of project.items.nodes) {
|
||||||
|
const content = item.content;
|
||||||
|
if (!content || !("number" in content)) continue;
|
||||||
|
|
||||||
|
const labels = content.labels.nodes.map((l) => l.name);
|
||||||
|
const statusName = item.fieldValues.nodes.find(
|
||||||
|
(n) => n.field?.name === STATUS_FIELD_NAME,
|
||||||
|
)?.name;
|
||||||
|
const status: IssueStatus = statusName
|
||||||
|
? (STATUS_NAME_TO_TYPE[statusName] ?? "Backlog")
|
||||||
|
: "Backlog";
|
||||||
|
|
||||||
|
issues.push({
|
||||||
|
number: content.number,
|
||||||
|
nodeId: content.id,
|
||||||
|
itemId: item.id,
|
||||||
|
title: content.title,
|
||||||
|
body: content.body ?? "",
|
||||||
|
kind: detectKindFromLabels(labels),
|
||||||
|
status,
|
||||||
|
blockedBy: parseBlockedByFromBody(content.body ?? ""),
|
||||||
|
assignee: content.assignees.nodes[0]?.login,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
projectId: project.id,
|
||||||
|
projectNumber,
|
||||||
|
ownerLogin,
|
||||||
|
repo,
|
||||||
|
issues,
|
||||||
|
},
|
||||||
|
refs: {
|
||||||
|
projectId: project.id,
|
||||||
|
statusFieldId: statusField.id,
|
||||||
|
statusOptionIds: statusOptionIds as Record<IssueStatus, string>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectStateClient {
|
||||||
|
constructor(
|
||||||
|
private readonly graphql: GraphqlClient,
|
||||||
|
private readonly ownerLogin: string,
|
||||||
|
private readonly repo: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async readProjectState(
|
||||||
|
projectNumber: number,
|
||||||
|
): Promise<{ state: ProjectState; refs: ProjectFieldRefs }> {
|
||||||
|
const raw = await this.graphql<RawProjectResponse>(PROJECT_QUERY, {
|
||||||
|
owner: this.ownerLogin,
|
||||||
|
repo: this.repo,
|
||||||
|
number: projectNumber,
|
||||||
|
});
|
||||||
|
return parseProjectResponse(
|
||||||
|
raw,
|
||||||
|
this.ownerLogin,
|
||||||
|
this.repo,
|
||||||
|
projectNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatus(
|
||||||
|
projectId: string,
|
||||||
|
itemId: string,
|
||||||
|
statusFieldId: string,
|
||||||
|
statusOptionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.graphql(
|
||||||
|
/* GraphQL */ `
|
||||||
|
mutation SetStatus(
|
||||||
|
$projectId: ID!
|
||||||
|
$itemId: ID!
|
||||||
|
$fieldId: ID!
|
||||||
|
$optionId: String!
|
||||||
|
) {
|
||||||
|
updateProjectV2ItemFieldValue(
|
||||||
|
input: {
|
||||||
|
projectId: $projectId
|
||||||
|
itemId: $itemId
|
||||||
|
fieldId: $fieldId
|
||||||
|
value: { singleSelectOptionId: $optionId }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
projectV2Item {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{ projectId, itemId, fieldId: statusFieldId, optionId: statusOptionId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignIssue(issueNodeId: string, userLogin: string): Promise<void> {
|
||||||
|
const userQuery = await this.graphql<{ user: { id: string } }>(
|
||||||
|
/* GraphQL */ `
|
||||||
|
query GetUser($login: String!) {
|
||||||
|
user(login: $login) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{ login: userLogin },
|
||||||
|
);
|
||||||
|
await this.graphql(
|
||||||
|
/* GraphQL */ `
|
||||||
|
mutation Assign($issueId: ID!, $userId: ID!) {
|
||||||
|
addAssigneesToAssignable(
|
||||||
|
input: { assignableId: $issueId, assigneeIds: [$userId] }
|
||||||
|
) {
|
||||||
|
assignable {
|
||||||
|
... on Issue {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{ issueId: issueNodeId, userId: userQuery.user.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async postIssueComment(issueNodeId: string, body: string): Promise<void> {
|
||||||
|
await this.graphql(
|
||||||
|
/* GraphQL */ `
|
||||||
|
mutation Comment($issueId: ID!, $body: String!) {
|
||||||
|
addComment(input: { subjectId: $issueId, body: $body }) {
|
||||||
|
commentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{ issueId: issueNodeId, body },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/modules/prompt-builder.test.ts
Normal file
96
src/modules/prompt-builder.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ProjectIssue } from "../types.js";
|
||||||
|
import { build } from "./prompt-builder.js";
|
||||||
|
|
||||||
|
const sampleIssue: ProjectIssue = {
|
||||||
|
number: 42,
|
||||||
|
nodeId: "node-42",
|
||||||
|
title: "Wire up assessment export",
|
||||||
|
body: [
|
||||||
|
"## What to build",
|
||||||
|
"",
|
||||||
|
"Export an assessment as a PDF.",
|
||||||
|
"",
|
||||||
|
"## Acceptance criteria",
|
||||||
|
"",
|
||||||
|
"- [ ] Endpoint POST /assessments/:id/export returns a PDF",
|
||||||
|
"- [ ] PDF includes the customer name and address",
|
||||||
|
].join("\n"),
|
||||||
|
kind: "AFK",
|
||||||
|
status: "Ready",
|
||||||
|
blockedBy: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("build", () => {
|
||||||
|
it("includes the issue number, title, and body", () => {
|
||||||
|
const out = build({ issue: sampleIssue, repoContext: {} });
|
||||||
|
expect(out).toContain("#42");
|
||||||
|
expect(out).toContain("Wire up assessment export");
|
||||||
|
expect(out).toContain("Export an assessment as a PDF.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the repo-context section when no context paths are provided", () => {
|
||||||
|
const out = build({ issue: sampleIssue, repoContext: {} });
|
||||||
|
expect(out).not.toContain("## Repo context");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes context paths when provided", () => {
|
||||||
|
const out = build({
|
||||||
|
issue: sampleIssue,
|
||||||
|
repoContext: { contextMdPath: "CONTEXT.md", adrDirPath: "docs/adr" },
|
||||||
|
});
|
||||||
|
expect(out).toContain("CONTEXT.md");
|
||||||
|
expect(out).toContain("docs/adr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("instructs the agent not to push or open a PR", () => {
|
||||||
|
const out = build({ issue: sampleIssue, repoContext: {} });
|
||||||
|
expect(out).toContain("DO NOT push");
|
||||||
|
expect(out).toContain("DO NOT open a pull request");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty body placeholder", () => {
|
||||||
|
const empty: ProjectIssue = { ...sampleIssue, body: "" };
|
||||||
|
const out = build({ issue: empty, repoContext: {} });
|
||||||
|
expect(out).toContain("_(empty)_");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches snapshot", () => {
|
||||||
|
const out = build({
|
||||||
|
issue: sampleIssue,
|
||||||
|
repoContext: { contextMdPath: "CONTEXT.md", adrDirPath: "docs/adr" },
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
"# Implement issue #42: Wire up assessment export
|
||||||
|
|
||||||
|
## Issue body
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Export an assessment as a PDF.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Endpoint POST /assessments/:id/export returns a PDF
|
||||||
|
- [ ] PDF includes the customer name and address
|
||||||
|
|
||||||
|
## Repo context
|
||||||
|
|
||||||
|
- Domain language: see \`CONTEXT.md\` for the project's vocabulary. Use those terms in code and commit messages.
|
||||||
|
- Architectural decisions: skim \`docs/adr\` for any ADR that touches the area you're changing. Respect existing decisions.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
If this slice has any test-shaped surface, follow a red-green-refactor TDD loop: write a failing test that captures an acceptance criterion, make it pass with the smallest change, refactor.
|
||||||
|
|
||||||
|
## Output contract
|
||||||
|
|
||||||
|
- Make as many commits on the current branch as needed to satisfy every acceptance criterion in the issue body.
|
||||||
|
- DO NOT push the branch.
|
||||||
|
- DO NOT open a pull request.
|
||||||
|
- DO NOT modify unrelated files outside the scope of this issue.
|
||||||
|
- Stop when every acceptance criterion is checked off.
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
src/modules/prompt-builder.ts
Normal file
53
src/modules/prompt-builder.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { ProjectIssue, RepoContext } from "../types.js";
|
||||||
|
|
||||||
|
export interface PromptInput {
|
||||||
|
issue: ProjectIssue;
|
||||||
|
repoContext: RepoContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TDD_HINT =
|
||||||
|
"If this slice has any test-shaped surface, follow a red-green-refactor TDD loop: write a failing test that captures an acceptance criterion, make it pass with the smallest change, refactor.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose the per-ticket agent prompt. Pure: same inputs => same string.
|
||||||
|
*
|
||||||
|
* The agent is instructed to commit only — the runner handles push/PR.
|
||||||
|
*/
|
||||||
|
export function build({ issue, repoContext }: PromptInput): string {
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
sections.push(`# Implement issue #${issue.number}: ${issue.title}`);
|
||||||
|
|
||||||
|
sections.push("## Issue body\n\n" + (issue.body.trim() || "_(empty)_"));
|
||||||
|
|
||||||
|
const contextLines: string[] = [];
|
||||||
|
if (repoContext.contextMdPath) {
|
||||||
|
contextLines.push(
|
||||||
|
`- Domain language: see \`${repoContext.contextMdPath}\` for the project's vocabulary. Use those terms in code and commit messages.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (repoContext.adrDirPath) {
|
||||||
|
contextLines.push(
|
||||||
|
`- Architectural decisions: skim \`${repoContext.adrDirPath}\` for any ADR that touches the area you're changing. Respect existing decisions.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (contextLines.length > 0) {
|
||||||
|
sections.push("## Repo context\n\n" + contextLines.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push("## Approach\n\n" + TDD_HINT);
|
||||||
|
|
||||||
|
sections.push(
|
||||||
|
[
|
||||||
|
"## Output contract",
|
||||||
|
"",
|
||||||
|
"- Make as many commits on the current branch as needed to satisfy every acceptance criterion in the issue body.",
|
||||||
|
"- DO NOT push the branch.",
|
||||||
|
"- DO NOT open a pull request.",
|
||||||
|
"- DO NOT modify unrelated files outside the scope of this issue.",
|
||||||
|
"- Stop when every acceptance criterion is checked off.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return sections.join("\n\n") + "\n";
|
||||||
|
}
|
||||||
74
src/types.ts
Normal file
74
src/types.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
export type Variant = "per-ticket" | "single-pr";
|
||||||
|
|
||||||
|
export type IssueKind = "AFK" | "HITL";
|
||||||
|
|
||||||
|
export type IssueStatus =
|
||||||
|
| "Backlog"
|
||||||
|
| "Ready"
|
||||||
|
| "In progress"
|
||||||
|
| "In review"
|
||||||
|
| "Needs human"
|
||||||
|
| "Done";
|
||||||
|
|
||||||
|
export interface ProjectIssue {
|
||||||
|
number: number;
|
||||||
|
nodeId: string;
|
||||||
|
/** ProjectV2Item id (the Project's row for this issue), distinct from `nodeId` (the Issue itself). */
|
||||||
|
itemId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
kind: IssueKind;
|
||||||
|
status: IssueStatus;
|
||||||
|
blockedBy: number[];
|
||||||
|
assignee?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectState {
|
||||||
|
projectId: string;
|
||||||
|
projectNumber: number;
|
||||||
|
ownerLogin: string;
|
||||||
|
repo: string;
|
||||||
|
issues: ProjectIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Phase {
|
||||||
|
index: number;
|
||||||
|
issues: ProjectIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FailureKind =
|
||||||
|
| "agent-error"
|
||||||
|
| "tests-failed"
|
||||||
|
| "build-failed"
|
||||||
|
| "sandbox-timeout"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export interface FailureContext {
|
||||||
|
variant: Variant;
|
||||||
|
retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FailureAction =
|
||||||
|
| { kind: "retry" }
|
||||||
|
| { kind: "skip"; reason: string }
|
||||||
|
| { kind: "halt"; reason: string };
|
||||||
|
|
||||||
|
export interface RepoContext {
|
||||||
|
contextMdPath?: string;
|
||||||
|
adrDirPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentResult {
|
||||||
|
success: boolean;
|
||||||
|
failure?: { kind: FailureKind; logs: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerOptions {
|
||||||
|
projectNumber: number;
|
||||||
|
variant: Variant;
|
||||||
|
targetRepoPath: string;
|
||||||
|
baseBranch?: string;
|
||||||
|
githubToken: string;
|
||||||
|
ownerLogin: string;
|
||||||
|
repo: string;
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue