Merge branch 'worktree-sandcastle-subscription-auth-v1': sandcastle subscription auth as primary flow
This commit is contained in:
15
.env.example
15
.env.example
@@ -54,7 +54,22 @@ CMS_URL=http://localhost:3001
|
||||
|
||||
# --- Optional: sandcastle dispatch (only when running `pnpm work dispatch --execute`) ---
|
||||
|
||||
# Auth (pick one — subscription is preferred):
|
||||
#
|
||||
# 1. Subscription mode (recommended for Pro/Max subscribers):
|
||||
# Run `claude login` on the host once. Sandcastle bind-mounts ~/.claude/
|
||||
# into the sandbox so the container's Claude Code CLI uses your session.
|
||||
# Zero per-task token spend. No env var needed.
|
||||
#
|
||||
# 2. API-key mode (fallback when no host creds available):
|
||||
# ANTHROPIC_API_KEY=
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# Override the path to host Claude Code creds (default: ~/.claude/)
|
||||
# SANDCASTLE_CLAUDE_CREDS_DIR=
|
||||
|
||||
# GitHub access (optional — for orchestrator-created PRs)
|
||||
# GITHUB_TOKEN=
|
||||
|
||||
# Sandbox provider (default: docker; alternatives: podman, vercel, daytona)
|
||||
# SANDCASTLE_PROVIDER=docker
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
# Anthropic Claude API
|
||||
ANTHROPIC_API_KEY=
|
||||
# .sandcastle/.env — runtime tokens for sandcastle dispatch.
|
||||
# Copy to .sandcastle/.env (gitignored) and fill what you need.
|
||||
#
|
||||
# Most developers don't need ANY of these if they've run `claude login` on
|
||||
# the host — sandcastle mounts ~/.claude/ into the sandbox by default.
|
||||
|
||||
# OpenAI / Codex
|
||||
OPENAI_API_KEY=
|
||||
# Anthropic API key (fallback when no host Claude Code session exists)
|
||||
# ANTHROPIC_API_KEY=
|
||||
|
||||
# GitHub (for PR creation by the orchestrator)
|
||||
GITHUB_TOKEN=
|
||||
# OpenAI / Codex (alternative)
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# Sandcastle's own configuration — sandbox provider (docker | podman | vercel | custom)
|
||||
# GitHub access for orchestrator-created PRs
|
||||
# GITHUB_TOKEN=
|
||||
|
||||
# Override Claude creds path (default: ~/.claude/)
|
||||
# SANDCASTLE_CLAUDE_CREDS_DIR=
|
||||
|
||||
# Sandbox provider (docker / podman / vercel / daytona)
|
||||
SANDCASTLE_PROVIDER=docker
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
# Minimal sandbox for sandcastle dispatch.
|
||||
# Will be extended once the CI image is identified (see ADR-future).
|
||||
# Sandcastle sandbox image — runs the implementer + reviewer agents.
|
||||
#
|
||||
# Includes Claude Code CLI so the sandbox can authenticate via the host's
|
||||
# mounted ~/.claude/ session (sandcastle issue #191 workaround — subscription
|
||||
# auth, not API-key auth, is our primary flow). Falls back to ANTHROPIC_API_KEY
|
||||
# when no host credentials are available.
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
# pnpm via corepack (matches the repo's pnpm version)
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
# Claude Code CLI — used by sandcastle's claudeCode() agent provider.
|
||||
# The CLI reads credentials from ~/.claude/ inside the container; the host
|
||||
# mounts its ~/.claude/ over that path at sandbox start.
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Minimal system deps for git operations + healthchecks.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Repo files are mounted by sandcastle at /workspace at runtime.
|
||||
# The agent runs `pnpm install --frozen-lockfile` as its first step
|
||||
# per the implementer prompt template.
|
||||
|
||||
CMD ["bash"]
|
||||
|
||||
@@ -50,12 +50,21 @@ Concretely:
|
||||
orchestrator does NOT mutate state in v1 — it prints suggested mutations
|
||||
for the human to apply.
|
||||
5. **Two modes:** `pnpm work dispatch` (planning, no agent invoked) and
|
||||
`pnpm work dispatch --execute` (real sandcastle call, requires
|
||||
`ANTHROPIC_API_KEY` or `OPENAI_API_KEY`).
|
||||
`pnpm work dispatch --execute` (real sandcastle call, requires auth — see
|
||||
point 7).
|
||||
6. **Reviewer agent verifies generator-first.** Hand-rolled output that should
|
||||
have been a `pnpm turbo gen <kind>` invocation is grounds for rejection.
|
||||
7. **Bring-your-own-key for cost control.** No bundled API key. Agents only
|
||||
dispatch when the operator explicitly provides credentials.
|
||||
7. **Bring-your-own-auth.** Two paths are supported, in priority order:
|
||||
- **Subscription (primary)** — bind-mount the host's `~/.claude/` into the
|
||||
sandbox. Claude Code CLI inside the sandbox uses the host's logged-in
|
||||
subscription session. Zero per-task token spend for Pro/Max subscribers.
|
||||
Path overridable via `SANDCASTLE_CLAUDE_CREDS_DIR` env var.
|
||||
- **API key (fallback)** — `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` passed
|
||||
through to the sandbox env. Used when no host creds directory exists.
|
||||
- The resolver (`resolveClaudeAuth` in `scripts/work/dispatch.mjs`) picks
|
||||
automatically with subscription always preferred. Sandcastle's own issue
|
||||
#191 documents that subscription support won't be added natively;
|
||||
this mount-based pattern is our workaround promoted to first-class.
|
||||
8. **Per-task max-attempts honoured (v2).** Each task's frontmatter may carry
|
||||
`max-attempts: N` to bound the implementer↔reviewer retry loop. Default 3.
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ pnpm work ready # all ready stories
|
||||
pnpm work blocked # blocked stories + what they wait on
|
||||
pnpm work rebuild-state # regenerate docs/work/_state.json
|
||||
pnpm work dispatch # print next dispatch plan
|
||||
pnpm work dispatch --execute # invoke sandcastle (requires ANTHROPIC_API_KEY)
|
||||
pnpm work dispatch --execute # invoke sandcastle (subscription or API key — see runbook)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -139,10 +139,13 @@ Copy `.env.example` to `.env` and fill what you need. NOT every variable is requ
|
||||
|
||||
### Optional — sandcastle dispatch (only when running `pnpm work dispatch --execute`)
|
||||
|
||||
Auth is resolved automatically. Subscription (via `~/.claude/`) is the primary path; API key is the fallback.
|
||||
|
||||
| Var | Why |
|
||||
| --------------------- | ------------------------------------------------- |
|
||||
| `ANTHROPIC_API_KEY` | Claude API key (sandcastle's default agent) |
|
||||
| `OPENAI_API_KEY` | OpenAI/Codex alternative |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `ANTHROPIC_API_KEY` | Claude API key — fallback when no `~/.claude/` present; not needed for subscribers |
|
||||
| `OPENAI_API_KEY` | OpenAI/Codex alternative (fallback) |
|
||||
| `SANDCASTLE_CLAUDE_CREDS_DIR` | Override host Claude creds path (default: `~/.claude/`) |
|
||||
| `GITHUB_TOKEN` | GitHub access for PR creation by the orchestrator |
|
||||
| `SANDCASTLE_PROVIDER` | `docker` (default) / `podman` / `vercel` |
|
||||
|
||||
@@ -239,17 +242,14 @@ For the full design see `docs/architecture/agent-first-workflow-and-conformance.
|
||||
### Prerequisites
|
||||
|
||||
1. **Docker running** — sandcastle uses Docker for the sandbox by default. `docker info` should succeed.
|
||||
2. **Agent API key** — set ONE of:
|
||||
- `ANTHROPIC_API_KEY` (recommended; sandcastle's default agent is `claudeCode`)
|
||||
- `OPENAI_API_KEY` (alternative)
|
||||
2. **Authentication — pick ONE:**
|
||||
- **Recommended: Claude Pro / Max subscription.** Run `claude login` once on the host. Sandcastle's sandbox bind-mounts your `~/.claude/` into the container so the Claude Code CLI inside the sandbox uses your subscription session. Zero per-task token spend for subscribers.
|
||||
- **Alternative: API key.** Set `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in your environment. Falls back automatically when `~/.claude/` is absent.
|
||||
- **Override the creds path** via `SANDCASTLE_CLAUDE_CREDS_DIR` if your Claude Code config lives somewhere non-standard.
|
||||
3. **GitHub token** (optional) — `GITHUB_TOKEN` if you want the orchestrator to create PRs.
|
||||
4. **`.sandcastle/` config present** — already in tree:
|
||||
- `Dockerfile` — node:22 + pnpm sandbox image
|
||||
- `prd-eliciter.prompt.md` — interviews humans to draft PRDs
|
||||
- `adr-eliciter.prompt.md` — same shape, for infrastructure decisions
|
||||
- `decomposer.prompt.md` — PRD → epic + stories with generator-first task lists
|
||||
- `implementer.prompt.md` — executes one task; runs all 5 gates before committing
|
||||
- `reviewer.prompt.md` — reviews implementer's diff against AC + scope
|
||||
- `Dockerfile` — node:22 + pnpm + Claude Code CLI; reads creds from `~/.claude/` inside the container
|
||||
- `prd-eliciter.prompt.md`, `adr-eliciter.prompt.md`, `decomposer.prompt.md`, `implementer.prompt.md`, `reviewer.prompt.md` — the five role prompts
|
||||
|
||||
### The dispatch flow
|
||||
|
||||
@@ -305,6 +305,11 @@ This is safe to run anywhere — it never invokes Sandcastle.
|
||||
**Step 2 — Execute**
|
||||
|
||||
```bash
|
||||
# Subscription mode (recommended):
|
||||
claude login # one-time, host
|
||||
pnpm work dispatch --execute # uses ~/.claude/
|
||||
|
||||
# API-key mode (fallback):
|
||||
ANTHROPIC_API_KEY=sk-ant-... pnpm work dispatch --execute
|
||||
```
|
||||
|
||||
@@ -361,8 +366,8 @@ You (the human) then:
|
||||
|
||||
### Troubleshooting Sandcastle
|
||||
|
||||
**`✗ --execute requires ANTHROPIC_API_KEY or OPENAI_API_KEY in env.`**
|
||||
— Set one. The default agent is Claude.
|
||||
**`✗ --execute requires either: 1. Claude Code logged in on host ... 2. ANTHROPIC_API_KEY ...`**
|
||||
— No auth resolved. Run `claude login` to enable subscription mode (recommended), OR set `ANTHROPIC_API_KEY` (fallback). Override the host creds path via `SANDCASTLE_CLAUDE_CREDS_DIR`.
|
||||
|
||||
**`Error: Cannot find module '@ai-hero/sandcastle'`**
|
||||
— Run `pnpm install`. Sandcastle is a dev dependency at the workspace root.
|
||||
@@ -381,6 +386,9 @@ You (the human) then:
|
||||
|
||||
**Cost control** — each dispatch typically uses 50K–200K agent tokens depending on task complexity. The orchestrator does NOT cap retries; if you want to limit, set `max-attempts: 1` in the task's frontmatter (the orchestrator respects this in v2 — for now, just don't re-run dispatch after a reject).
|
||||
|
||||
**Sandbox boots but Claude Code inside it says "Not authenticated" / "API key required"**
|
||||
— The host `~/.claude/` mount didn't make it into the sandbox, OR your local Claude Code session expired. On the host, run `claude` once to confirm your session is live, then re-dispatch. If you're on Linux + SELinux, the mount may have been blocked — check the sandcastle output for SELinux warnings; set `selinuxLabel: "z"` or `false` in dispatch.mjs's docker opts if needed.
|
||||
|
||||
### Cost-aware variant: planning-only loop
|
||||
|
||||
If you want sandcastle's structure without the agent spend, use planning mode + manual execution:
|
||||
@@ -417,8 +425,8 @@ This gives you the same DAG-aware "what's next?" without invoking any agent. Use
|
||||
**Tests fail in `@repo/turbo-generators` with Vitest worker timeouts**
|
||||
— Known flaky on slow machines. Re-run; if persistent, increase the `turbo-generators` package's vitest `testTimeout`.
|
||||
|
||||
**`pnpm work dispatch --execute` errors with "ANTHROPIC_API_KEY required"**
|
||||
— You're trying to run the sandcastle orchestrator without an agent API key. Set the env var, or run `pnpm work dispatch` (no flag) to just print the plan.
|
||||
**`pnpm work dispatch --execute` errors with "requires either: 1. Claude Code logged in..."**
|
||||
— No auth source found. Run `claude login` (subscription mode, recommended), or set `ANTHROPIC_API_KEY` (fallback). Run `pnpm work dispatch` (no flag) to just print the plan without auth.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updated_at": "2026-05-13T07:05:55.913Z",
|
||||
"updated_at": "2026-05-13T07:32:16.097Z",
|
||||
"epics": {
|
||||
"agent-workflow-docs-v1": {
|
||||
"status": "done",
|
||||
@@ -382,6 +382,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sandcastle-subscription-auth-v1": {
|
||||
"status": "done",
|
||||
"title": "Sandcastle subscription auth as primary flow",
|
||||
"stories": {
|
||||
"01-subscription-first": {
|
||||
"status": "done",
|
||||
"title": "Subscription-first dispatch — mount ~/.claude, install claude CLI, docs",
|
||||
"ac_total": 8,
|
||||
"ac_completed": 8,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"visual-regression-v1": {
|
||||
"status": "done",
|
||||
"title": "Visual regression v1 — Playwright screenshot infrastructure",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
id: 01-subscription-first
|
||||
epic: sandcastle-subscription-auth-v1
|
||||
title: Subscription-first dispatch — mount ~/.claude, install claude CLI, docs
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Story scaffold
|
||||
- [x] Update .sandcastle/Dockerfile to install @anthropic-ai/claude-code CLI
|
||||
- [x] Update scripts/work/dispatch.mjs: resolveClaudeAuth helper + sandbox mount + fallback to API key
|
||||
- [x] Tests for resolveClaudeAuth
|
||||
- [x] Update runbook "Using Sandcastle" section: subscription is primary flow
|
||||
- [x] Update ADR-019: subscription auth as primary, API key as fallback
|
||||
- [x] Update .env.example + .sandcastle/.env.example: reframe ANTHROPIC_API_KEY as fallback
|
||||
- [x] Final verification + closeout
|
||||
29
docs/work/sandcastle-subscription-auth-v1/_epic.md
Normal file
29
docs/work/sandcastle-subscription-auth-v1/_epic.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
id: sandcastle-subscription-auth-v1
|
||||
prd: null
|
||||
title: Sandcastle subscription auth as primary flow
|
||||
type: epic
|
||||
status: done
|
||||
features: [tooling, ci]
|
||||
created: 2026-05-13
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Subscription-based Claude Code auth (via host `~/.claude` mount) becomes
|
||||
the default for `pnpm work dispatch --execute`. API-key auth remains as a
|
||||
fallback. Works for any developer who has run `claude login` on their
|
||||
host — zero per-user configuration in the repo.
|
||||
|
||||
## Why
|
||||
|
||||
The sandcastle community has explicitly rejected native subscription
|
||||
support (issue #191, wontfix). But the host's `~/.claude/` directory
|
||||
contains the logged-in session; mounting it into the sandbox lets the
|
||||
container's Claude Code CLI use that session. This pattern is
|
||||
environment-agnostic (each developer has their own `~/.claude`) and
|
||||
spends zero per-task agent tokens for Pro/Max subscribers.
|
||||
|
||||
## Stories
|
||||
|
||||
- [x] [01 — Subscription-first dispatch + Dockerfile + docs](01-subscription-first/_story.md)
|
||||
@@ -4,9 +4,12 @@
|
||||
* (with --execute) invokes sandcastle to run the implementer then reviewer.
|
||||
*
|
||||
* Default mode prints the dispatch plan without invoking sandcastle —
|
||||
* safe to run anywhere. --execute requires ANTHROPIC_API_KEY in the env.
|
||||
* safe to run anywhere. --execute requires EITHER:
|
||||
* 1. Claude Code logged in on host (~/.claude/ — recommended for subscribers)
|
||||
* 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execSync } from "node:child_process";
|
||||
@@ -86,6 +89,54 @@ ${next.bulletLine.trim()}
|
||||
${next.storyContent}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the auth method for sandcastle dispatch.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Subscription (primary) — mount host's ~/.claude/ into the sandbox.
|
||||
* Active when the host's Claude creds directory exists. The path
|
||||
* defaults to ~/.claude/ and can be overridden via the
|
||||
* SANDCASTLE_CLAUDE_CREDS_DIR env var.
|
||||
* 2. API key (fallback) — pass ANTHROPIC_API_KEY (or OPENAI_API_KEY)
|
||||
* through to the sandbox env.
|
||||
* 3. Neither available → returns { mode: "missing" } and the dispatcher
|
||||
* prints a clear error before exiting.
|
||||
*
|
||||
* Returns: { mode: "subscription", hostPath, sandboxPath }
|
||||
* | { mode: "api-key", env }
|
||||
* | { mode: "missing" }
|
||||
*/
|
||||
export function resolveClaudeAuth({
|
||||
env = process.env,
|
||||
home = os.homedir(),
|
||||
} = {}) {
|
||||
// 1. Subscription path
|
||||
const credsHostPath =
|
||||
env.SANDCASTLE_CLAUDE_CREDS_DIR ?? path.join(home, ".claude");
|
||||
if (fs.existsSync(credsHostPath)) {
|
||||
return {
|
||||
mode: "subscription",
|
||||
hostPath: credsHostPath,
|
||||
// Inside the sandbox, claude looks at the agent user's home — tilde
|
||||
// expansion in MountConfig handles the actual /home/agent/.claude
|
||||
// resolution.
|
||||
sandboxPath: "~/.claude",
|
||||
};
|
||||
}
|
||||
// 2. API key fallback
|
||||
if (env.ANTHROPIC_API_KEY) {
|
||||
return {
|
||||
mode: "api-key",
|
||||
env: { ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY },
|
||||
};
|
||||
}
|
||||
if (env.OPENAI_API_KEY) {
|
||||
return { mode: "api-key", env: { OPENAI_API_KEY: env.OPENAI_API_KEY } };
|
||||
}
|
||||
// 3. Neither available
|
||||
return { mode: "missing" };
|
||||
}
|
||||
|
||||
function printPlan() {
|
||||
const next = findNextTask();
|
||||
if (!next) {
|
||||
@@ -99,11 +150,16 @@ function printPlan() {
|
||||
console.log(` Bullet: ${next.bulletLine.trim()}`);
|
||||
console.log(` Prompt: .sandcastle/implementer.prompt.md`);
|
||||
console.log();
|
||||
console.log("To execute this dispatch, run:");
|
||||
console.log(" ANTHROPIC_API_KEY=... pnpm work dispatch --execute");
|
||||
console.log("To execute this dispatch:");
|
||||
console.log(
|
||||
" - With Claude subscription: `claude login` (one-time) then `pnpm work dispatch --execute`",
|
||||
);
|
||||
console.log(
|
||||
" - With API key: `ANTHROPIC_API_KEY=... pnpm work dispatch --execute`",
|
||||
);
|
||||
console.log();
|
||||
console.log(
|
||||
"(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and an agent API key.)",
|
||||
"(Execute mode requires @ai-hero/sandcastle, a sandbox provider, and auth — see above.)",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,24 +169,34 @@ async function executeDispatch() {
|
||||
console.log("No ready task to dispatch.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (!process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) {
|
||||
|
||||
const auth = resolveClaudeAuth();
|
||||
if (auth.mode === "missing") {
|
||||
console.error("✗ --execute requires either:");
|
||||
console.error(
|
||||
"✗ --execute requires ANTHROPIC_API_KEY or OPENAI_API_KEY in env.",
|
||||
" 1. Claude Code logged in on host (run `claude login` first; ~/.claude/ becomes the auth source — this is the recommended path for Pro/Max subscribers)",
|
||||
);
|
||||
console.error(" 2. ANTHROPIC_API_KEY or OPENAI_API_KEY in env (fallback)");
|
||||
console.error("");
|
||||
console.error(
|
||||
" Override Claude creds path via SANDCASTLE_CLAUDE_CREDS_DIR.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Auth mode: ${auth.mode === "subscription" ? `subscription (mounting ${auth.hostPath})` : "api-key"}`,
|
||||
);
|
||||
console.log(
|
||||
`Dispatching: ${next.epic} / ${next.story} / ${next.bulletLine.trim()}`,
|
||||
);
|
||||
const taskSpec = buildTaskSpec(next);
|
||||
|
||||
let sandcastle;
|
||||
let dockerSandbox;
|
||||
let sandcastleRoot;
|
||||
let dockerProvider;
|
||||
try {
|
||||
sandcastle = await import("@ai-hero/sandcastle");
|
||||
({ docker: dockerSandbox } =
|
||||
await import("@ai-hero/sandcastle/sandboxes/docker"));
|
||||
sandcastleRoot = await import("@ai-hero/sandcastle");
|
||||
const dockerModule = await import("@ai-hero/sandcastle/sandboxes/docker");
|
||||
dockerProvider = dockerModule.docker;
|
||||
} catch {
|
||||
console.error(
|
||||
"✗ @ai-hero/sandcastle is not installed. Run `pnpm install` first.",
|
||||
@@ -138,14 +204,30 @@ async function executeDispatch() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build sandbox + agent providers based on auth mode
|
||||
const dockerOpts = {};
|
||||
const agentOpts = {};
|
||||
if (auth.mode === "subscription") {
|
||||
dockerOpts.mounts = [
|
||||
{
|
||||
hostPath: auth.hostPath,
|
||||
sandboxPath: auth.sandboxPath,
|
||||
readonly: false,
|
||||
},
|
||||
];
|
||||
} else if (auth.mode === "api-key") {
|
||||
agentOpts.env = auth.env;
|
||||
}
|
||||
const sandbox = dockerProvider(dockerOpts);
|
||||
const agent = sandcastleRoot.claudeCode("claude-sonnet-4-6", agentOpts);
|
||||
|
||||
// Implementer
|
||||
const implementerPrompt = path.join(SANDCASTLE_DIR, "implementer.prompt.md");
|
||||
let implResult;
|
||||
try {
|
||||
implResult = await sandcastle.run({
|
||||
agent: sandcastle.claudeCode("claude-sonnet-4-6"),
|
||||
sandbox: dockerSandbox({
|
||||
imageName: `sandcastle-dispatch:local`,
|
||||
}),
|
||||
implResult = await sandcastleRoot.run({
|
||||
agent,
|
||||
sandbox,
|
||||
promptFile: implementerPrompt,
|
||||
promptArgs: { TASK_FILE_CONTENT: taskSpec },
|
||||
cwd: REPO_ROOT,
|
||||
@@ -153,7 +235,7 @@ async function executeDispatch() {
|
||||
} catch (e) {
|
||||
console.error("✗ Implementer dispatch failed:", e.message);
|
||||
console.error(
|
||||
" See .sandcastle/README.md for setup. Provider name(s) may need updating.",
|
||||
" See docs/guides/runbook.md → 'Using Sandcastle' for setup.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -161,7 +243,7 @@ async function executeDispatch() {
|
||||
`Implementer returned. Branch: ${implResult.branch}, Commits: ${implResult.commits.length}`,
|
||||
);
|
||||
|
||||
// Reviewer: pass the diff as DIFF prompt variable.
|
||||
// Reviewer
|
||||
let diff = "";
|
||||
try {
|
||||
diff = execSync(`git diff main..${implResult.branch}`, {
|
||||
@@ -172,18 +254,16 @@ async function executeDispatch() {
|
||||
diff = "(diff unavailable)";
|
||||
}
|
||||
const reviewerPrompt = path.join(SANDCASTLE_DIR, "reviewer.prompt.md");
|
||||
const reviewResult = await sandcastle.run({
|
||||
agent: sandcastle.claudeCode("claude-sonnet-4-6"),
|
||||
sandbox: dockerSandbox({
|
||||
imageName: `sandcastle-dispatch:local`,
|
||||
}),
|
||||
const reviewResult = await sandcastleRoot.run({
|
||||
agent,
|
||||
sandbox,
|
||||
promptFile: reviewerPrompt,
|
||||
promptArgs: { TASK_FILE_CONTENT: taskSpec, DIFF: diff },
|
||||
cwd: REPO_ROOT,
|
||||
});
|
||||
console.log(`Reviewer returned. stdout follows:\n${reviewResult.stdout}`);
|
||||
|
||||
// V1: the orchestrator does NOT auto-mutate state. Print what should happen.
|
||||
// V1: orchestrator does NOT auto-mutate state. Print what should happen.
|
||||
console.log();
|
||||
console.log("=== Suggested state mutation ===");
|
||||
console.log(` Edit ${next.storyPath} — tick the bullet:`);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
findNextTask,
|
||||
findFirstUncheckedBullet,
|
||||
buildTaskSpec,
|
||||
resolveClaudeAuth,
|
||||
} from "./dispatch.mjs";
|
||||
|
||||
function makeWorkTree({ epics }) {
|
||||
@@ -137,3 +138,63 @@ describe("buildTaskSpec", () => {
|
||||
expect(spec).toContain("## Goal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveClaudeAuth", () => {
|
||||
it("returns subscription mode when ~/.claude exists on host", () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-sub-"));
|
||||
fs.mkdirSync(path.join(tmpHome, ".claude"));
|
||||
const result = resolveClaudeAuth({ env: {}, home: tmpHome });
|
||||
expect(result.mode).toBe("subscription");
|
||||
expect(result.hostPath).toBe(path.join(tmpHome, ".claude"));
|
||||
expect(result.sandboxPath).toBe("~/.claude");
|
||||
});
|
||||
|
||||
it("honours SANDCASTLE_CLAUDE_CREDS_DIR override", () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "auth-override-"));
|
||||
const overrideDir = path.join(tmpRoot, "custom-claude");
|
||||
fs.mkdirSync(overrideDir);
|
||||
const result = resolveClaudeAuth({
|
||||
env: { SANDCASTLE_CLAUDE_CREDS_DIR: overrideDir },
|
||||
home: "/nonexistent",
|
||||
});
|
||||
expect(result.mode).toBe("subscription");
|
||||
expect(result.hostPath).toBe(overrideDir);
|
||||
});
|
||||
|
||||
it("falls back to ANTHROPIC_API_KEY when ~/.claude does not exist", () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-key-"));
|
||||
// No .claude directory created
|
||||
const result = resolveClaudeAuth({
|
||||
env: { ANTHROPIC_API_KEY: "sk-test" },
|
||||
home: tmpHome,
|
||||
});
|
||||
expect(result.mode).toBe("api-key");
|
||||
expect(result.env).toEqual({ ANTHROPIC_API_KEY: "sk-test" });
|
||||
});
|
||||
|
||||
it("falls back to OPENAI_API_KEY when only that is set", () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-openai-"));
|
||||
const result = resolveClaudeAuth({
|
||||
env: { OPENAI_API_KEY: "sk-openai" },
|
||||
home: tmpHome,
|
||||
});
|
||||
expect(result.mode).toBe("api-key");
|
||||
expect(result.env).toEqual({ OPENAI_API_KEY: "sk-openai" });
|
||||
});
|
||||
|
||||
it("returns missing when neither subscription nor API key available", () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-missing-"));
|
||||
const result = resolveClaudeAuth({ env: {}, home: tmpHome });
|
||||
expect(result.mode).toBe("missing");
|
||||
});
|
||||
|
||||
it("prefers subscription over API key when both available", () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "auth-both-"));
|
||||
fs.mkdirSync(path.join(tmpHome, ".claude"));
|
||||
const result = resolveClaudeAuth({
|
||||
env: { ANTHROPIC_API_KEY: "sk-test" },
|
||||
home: tmpHome,
|
||||
});
|
||||
expect(result.mode).toBe("subscription");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user