5 Commits

Author SHA1 Message Date
ae86d95155 feat(sprint-002): Skill 강제 진입 + Bypass 감지
Sprint 002 전체 구현 — F1 (자매 skill bypass) 해결:

Enforcement core:
- src/enforcement/skill-context.ts — 스킬 컨텍스트 생성/읽기/삭제/만료 체크
- src/enforcement/skill-trace.ts — 도구 사용 추적 (JSONL append)
- src/enforcement/guard.ts — pre-tool 가드 (context 유무 + 만료 + escape hatch)

Hooks (실제 로직):
- hooks/pre-tool.sh — Write/Edit/Bash 게이트 (context 없으면 exit 2)
- hooks/post-tool.sh — 도구 사용 trace 자동 기록

CLI:
- rails skill-context {create|show|clear}
- rails skill-trace {show|blocked}

Tests (13 신규, 22 total pass):
- skill-context: CRUD + 만료 감지
- skill-trace: append + read + blocked count
- guard: no-context 차단, valid 허용, expired 차단, RAILS_ENFORCE=off escape hatch

검증: tsc --noEmit ✓ | vitest 22/22 ✓ | build ✓ | rails --help ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:56:58 +09:00
813c65077a docs: Sprint 001 완료 마크 + 현재 스프린트 002로 갱신 2026-04-10 13:53:02 +09:00
ac47b91bb5 merge: Sprint 001 — XState FSM + Prisma + CLI skeleton (#1) 2026-04-10 13:52:39 +09:00
0af4bbc685 feat(sprint-001): XState FSM + Prisma + CLI 뼈대 — 결정론적 파이프라인 코어
Sprint 001 전체 구현:

Foundation:
- package.json (pnpm + Node 22 + TypeScript strict)
- tsconfig.json (strict + noUncheckedIndexedAccess)
- .env.example (DATABASE_URL, DISCORD_TOKEN, etc.)
- vitest.config.ts

Core:
- src/env.ts — Zod 환경변수 검증
- src/logger.ts — pino 구조화 로거
- src/orchestrator/events.ts — Zod discriminated union 이벤트 스키마
- src/orchestrator/context.ts — PipelineContext 타입 + 팩토리
- src/orchestrator/machine.ts — XState v5 결정론적 FSM
  States: idle → planning → implementing → reviewing → deploying → done
  + retrying (exponential backoff 준비) + escalated + aborted
- src/orchestrator/persist.ts — Prisma 기반 상태 영속화
- prisma/schema.prisma — MariaDB 스키마 (pipelines, state_transitions, actor_spawns, contracts)

CLI (citty):
- rails start <project> — 파이프라인 생성
- rails status [id] — 상태 조회 + 타임라인
- rails serve — 오케스트레이터 서버 (Sprint 004 에서 완성)

Tests (9/9 pass):
- happy path (idle → done)
- REQUEST_CHANGES 재작업 루프 + max review round escalation
- retryable/non-retryable 에러 분기
- RESUME / ABORT
- context 추적

검증: pnpm tsc --noEmit ✓ | pnpm vitest run 9/9 ✓ | pnpm build ✓ | rails --help ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:46:13 +09:00
c32caf6034 chore: Sprint 001 착수 — cc:WIP 2026-04-10 13:38:57 +09:00
27 changed files with 3236 additions and 13 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# hanarang-rails environment variables
# Copy to .env and fill in values.
# ── Database (MariaDB / MySQL) ──
DATABASE_URL="mysql://rails:CHANGE_ME@localhost:3306/hanarang_rails"
# ── Discord ──
DISCORD_TOKEN=""
DISCORD_GUILD_ID=""
# ── Gitea Webhook ──
GITEA_WEBHOOK_SECRET=""
# ── Rails ──
RAILS_PORT=18800
RAILS_LOG_LEVEL=info
NODE_ENV=production

1
.gitignore vendored
View File

@@ -40,3 +40,4 @@ logs/
.claude/projects/
.claude/todos/
.claude/tool-results/
dist/

View File

@@ -16,8 +16,8 @@
| # | Sprint | 상세 | Status |
|---|---|---|---|
| 0 | 세이프티 네트 + 실패 감사 + 프로젝트 세팅 | [SPRINT-000](.plans/sprints/SPRINT-000-safety-and-audit.md) | cc:완료 [bac114d] |
| 1 | 스켈레톤: XState FSM + orchestrator + .plans/ 스캐폴딩 | [SPRINT-001](.plans/sprints/SPRINT-001-skeleton.md) | cc:TODO |
| 2 | Skill 강제 진입 hook + bypass 감지 + 차단 | [SPRINT-002](.plans/sprints/SPRINT-002-enforcement.md) | cc:TODO |
| 1 | 스켈레톤: XState FSM + orchestrator + .plans/ 스캐폴딩 | [SPRINT-001](.plans/sprints/SPRINT-001-skeleton.md) | cc:완료 [PR#1] |
| 2 | Skill 강제 진입 hook + bypass 감지 + 차단 | [SPRINT-002](.plans/sprints/SPRINT-002-enforcement.md) | cc:WIP |
| 3 | Sprint Contract + DoD validator (Zod) | [SPRINT-003](.plans/sprints/SPRINT-003-contract.md) | cc:TODO |
| 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:TODO |
| 5 | 재시도 / 타임아웃 / 에스컬레이션 policy | [SPRINT-005](.plans/sprints/SPRINT-005-resilience.md) | cc:TODO |
@@ -26,7 +26,7 @@
## 현재 스프린트
**Sprint 001스켈레톤: XState FSM + orchestrator + CLI** (`cc:TODO`)
**Sprint 002Skill 강제 진입 + Bypass 감지** (`cc:WIP`)
다음 착수 예정. 상세는 `.plans/sprints/SPRINT-001-skeleton.md` 참조.

View File

@@ -1,8 +1,40 @@
#!/usr/bin/env bash
# hanarang-rails post-tool hook (thin shim)
# 현재 no-op — Sprint 002 에서 skill bypass 감지 + revert 로직 주입 예정.
# 입력: stdin 으로 tool use result JSON
# 출력: exit 0 = proceed
# hanarang-rails post-tool hook
# Appends tool usage to skill trace for audit.
# Input: stdin JSON event from Claude Code
# Exit: always 0 (post-hook should not block)
set -euo pipefail
EVENT=$(cat)
CWD="${CLAUDE_PROJECT_DIR:-$(pwd)}"
TRACE_FILE="$CWD/.rails/skill-trace.jsonl"
# Ensure directory
mkdir -p "$(dirname "$TRACE_FILE")"
# Extract fields
TOOL=$(echo "$EVENT" | jq -r '.tool_name // "unknown"' 2>/dev/null || echo "unknown")
SESSION_ID="${CLAUDE_SESSION_ID:-}"
# Read pipeline ID from context if available
PIPELINE_ID=""
CONTEXT_FILE="$CWD/.rails/skill-context.json"
if [[ -f "$CONTEXT_FILE" ]]; then
PIPELINE_ID=$(jq -r '.pipelineId // ""' "$CONTEXT_FILE" 2>/dev/null || true)
fi
# Append trace entry
ENTRY=$(jq -n \
--argjson ts "$(date +%s)000" \
--arg tool "$TOOL" \
--arg cwd "$CWD" \
--arg sessionId "$SESSION_ID" \
--arg pipelineId "$PIPELINE_ID" \
'{ts: $ts, tool: $tool, cwd: $cwd, sessionId: $sessionId, pipelineId: $pipelineId, blocked: false, reason: "post-trace"}' \
2>/dev/null || true)
if [[ -n "$ENTRY" ]]; then
echo "$ENTRY" >> "$TRACE_FILE"
fi
exit 0

View File

@@ -1,8 +1,50 @@
#!/usr/bin/env bash
# hanarang-rails pre-tool hook (thin shim)
# 현재 no-op — Sprint 002 에서 skill-enforcement 로직 주입 예정.
# 입력: stdin 으로 tool use event JSON
# 출력: exit 0 = proceed, exit 2 = block
# hanarang-rails pre-tool hook
# Blocks Write/Edit/Bash if no valid skill context exists.
# Input: stdin JSON event from Claude Code
# Exit: 0 = allow, 2 = block
set -euo pipefail
# Escape hatch
if [[ "${RAILS_ENFORCE:-on}" == "off" ]]; then
exit 0
fi
# Read tool event from stdin
EVENT=$(cat)
TOOL=$(echo "$EVENT" | jq -r '.tool_name // empty' 2>/dev/null || true)
# Only gate Write, Edit, Bash
case "$TOOL" in
Write|Edit|Bash) ;;
*) exit 0 ;;
esac
# Find project root
CWD="${CLAUDE_PROJECT_DIR:-$(pwd)}"
CONTEXT_FILE="$CWD/.rails/skill-context.json"
# Check context exists
if [[ ! -f "$CONTEXT_FILE" ]]; then
echo "[rails-enforce] No skill context. Enter the pipeline via /rails first." >&2
exit 2
fi
# Check context not expired (TTL check)
if command -v jq >/dev/null 2>&1; then
CREATED=$(jq -r '.createdAt // empty' "$CONTEXT_FILE" 2>/dev/null || true)
TTL=$(jq -r '.ttlSeconds // 300' "$CONTEXT_FILE" 2>/dev/null || echo 300)
if [[ -n "$CREATED" ]]; then
CREATED_EPOCH=$(date -d "$CREATED" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${CREATED%%.*}" +%s 2>/dev/null || echo 0)
NOW_EPOCH=$(date +%s)
AGE=$(( NOW_EPOCH - CREATED_EPOCH ))
if [[ "$AGE" -gt "$TTL" ]]; then
echo "[rails-enforce] Skill context expired (age: ${AGE}s > ttl: ${TTL}s). Re-enter the skill." >&2
exit 2
fi
fi
fi
exit 0

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "hanarang-rails",
"version": "0.1.0",
"description": "Deterministic multi-agent pipeline orchestrator",
"type": "module",
"engines": {
"node": ">=22"
},
"bin": {
"rails": "dist/cli/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/cli/index.js serve",
"rails": "node dist/cli/index.js",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push"
},
"dependencies": {
"@prisma/client": "^6.6.0",
"citty": "^0.1.6",
"neverthrow": "^8.2.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"ulid": "^2.3.0",
"xstate": "^5.19.0",
"yaml": "^2.7.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"prisma": "^6.6.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
},
"packageManager": "pnpm@9.15.0"
}

1536
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

74
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,74 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Pipeline {
id String @id @db.VarChar(26) // ULID
projectName String @db.VarChar(255)
requirements String @db.Text
currentState String @db.VarChar(50) @default("idle")
contextJson String @db.LongText
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transitions StateTransition[]
actorSpawns ActorSpawn[]
contracts Contract[]
@@index([currentState])
@@index([createdAt])
@@map("pipelines")
}
model StateTransition {
id Int @id @default(autoincrement())
pipelineId String @db.VarChar(26)
fromState String @db.VarChar(50)
toState String @db.VarChar(50)
eventType String @db.VarChar(50)
eventPayload String @db.LongText
timestamp DateTime @default(now())
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
@@index([pipelineId, timestamp])
@@index([eventType])
@@map("state_transitions")
}
model ActorSpawn {
id Int @id @default(autoincrement())
pipelineId String @db.VarChar(26)
actorName String @db.VarChar(100)
stage String @db.VarChar(50)
spawnedAt DateTime @default(now())
exitCode Int?
exitedAt DateTime?
resultJson String? @db.LongText
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
@@index([pipelineId, spawnedAt])
@@map("actor_spawns")
}
model Contract {
id String @id @db.VarChar(26) // ULID
pipelineId String @db.VarChar(26)
sprintId String @db.VarChar(100)
version String @db.VarChar(20) @default("v1")
bodyJson String @db.LongText
frozenAt DateTime?
createdAt DateTime @default(now())
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
@@index([pipelineId])
@@index([sprintId])
@@map("contracts")
}

21
src/cli/index.ts Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
import { defineCommand, runMain } from "citty";
const main = defineCommand({
meta: {
name: "rails",
version: "0.1.0",
description: "Deterministic multi-agent pipeline orchestrator",
},
subCommands: {
start: () => import("./start.js").then((m) => m.default),
status: () => import("./status.js").then((m) => m.default),
serve: () => import("./serve.js").then((m) => m.default),
"skill-context": () =>
import("./skill-context.js").then((m) => m.default),
"skill-trace": () =>
import("./skill-trace.js").then((m) => m.default),
},
});
runMain(main);

28
src/cli/serve.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineCommand } from "citty";
import { loadEnv } from "../env.js";
import { getLogger } from "../logger.js";
export default defineCommand({
meta: {
name: "serve",
description: "Start the Rails orchestrator server (webhook + Discord bot)",
},
async run() {
const env = loadEnv();
const log = getLogger();
log.info(
{ port: env.RAILS_PORT, nodeEnv: env.NODE_ENV },
"hanarang-rails starting",
);
// TODO (Sprint 004): Discord bot initialization
// TODO (Sprint 004): Gitea webhook HTTP server
// For now, just keep the process alive
log.info("Orchestrator running. Press Ctrl+C to stop.");
await new Promise<never>(() => {
// keep alive until signal
});
},
});

83
src/cli/skill-context.ts Normal file
View File

@@ -0,0 +1,83 @@
import { defineCommand } from "citty";
import {
createSkillContext,
readSkillContext,
clearSkillContext,
contextAgeSeconds,
isContextExpired,
} from "../enforcement/skill-context.js";
export default defineCommand({
meta: {
name: "skill-context",
description: "Manage skill enforcement context",
},
args: {
action: {
type: "positional",
description: "Action: create | show | clear",
required: true,
},
skillName: {
type: "string",
alias: "s",
description: "Skill name (for create)",
default: "rails",
},
pipelineId: {
type: "string",
alias: "p",
description: "Pipeline ID (for create)",
default: "",
},
ttl: {
type: "string",
description: "TTL in seconds (for create)",
default: "300",
},
},
async run({ args }) {
const cwd = process.cwd();
switch (args.action) {
case "create": {
const ctx = await createSkillContext(cwd, {
skillName: args.skillName,
pipelineId: args.pipelineId,
ttlSeconds: parseInt(args.ttl, 10) || 300,
});
console.log(`Skill context created:`);
console.log(` skill: ${ctx.skillName}`);
console.log(` pipeline: ${ctx.pipelineId || "(none)"}`);
console.log(` ttl: ${ctx.ttlSeconds}s`);
console.log(` created: ${ctx.createdAt}`);
break;
}
case "show": {
const ctx = await readSkillContext(cwd);
if (!ctx) {
console.log("No skill context found.");
return;
}
const age = contextAgeSeconds(ctx);
const expired = isContextExpired(ctx);
console.log(`Skill context:`);
console.log(` skill: ${ctx.skillName}`);
console.log(` pipeline: ${ctx.pipelineId || "(none)"}`);
console.log(` session: ${ctx.sessionId || "(none)"}`);
console.log(` created: ${ctx.createdAt}`);
console.log(` age: ${age}s / ${ctx.ttlSeconds}s`);
console.log(` expired: ${expired}`);
break;
}
case "clear": {
const cleared = await clearSkillContext(cwd);
console.log(cleared ? "Skill context cleared." : "No context to clear.");
break;
}
default:
console.error(`Unknown action: ${args.action}. Use create | show | clear.`);
process.exitCode = 1;
}
},
});

68
src/cli/skill-trace.ts Normal file
View File

@@ -0,0 +1,68 @@
import { defineCommand } from "citty";
import { readTrace, countBlocked } from "../enforcement/skill-trace.js";
export default defineCommand({
meta: {
name: "skill-trace",
description: "View skill enforcement trace log",
},
args: {
action: {
type: "positional",
description: "Action: show | blocked",
required: false,
default: "show",
},
pipelineId: {
type: "string",
alias: "p",
description: "Filter by pipeline ID",
default: "",
},
limit: {
type: "string",
alias: "n",
description: "Number of entries to show",
default: "20",
},
},
async run({ args }) {
const cwd = process.cwd();
const action = args.action || "show";
switch (action) {
case "show": {
const entries = await readTrace(cwd, {
pipelineId: args.pipelineId || undefined,
limit: parseInt(args.limit, 10) || 20,
});
if (entries.length === 0) {
console.log("No trace entries found.");
return;
}
console.log(
`${"TIMESTAMP".padEnd(15)} ${"TOOL".padEnd(10)} ${"BLOCKED".padEnd(8)} REASON`,
);
console.log("-".repeat(60));
for (const e of entries) {
const time = new Date(e.ts).toISOString().slice(11, 19);
console.log(
`${time.padEnd(15)} ${e.tool.padEnd(10)} ${String(e.blocked).padEnd(8)} ${e.reason}`,
);
}
console.log(`\nTotal: ${entries.length} entries`);
break;
}
case "blocked": {
const count = await countBlocked(cwd);
console.log(`Blocked tool calls: ${count}`);
break;
}
default:
console.error(`Unknown action: ${action}. Use show | blocked.`);
process.exitCode = 1;
}
},
});

40
src/cli/start.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineCommand } from "citty";
import { loadEnv } from "../env.js";
import { createPipeline, disconnectPrisma } from "../orchestrator/persist.js";
export default defineCommand({
meta: {
name: "start",
description: "Start a new pipeline for a project",
},
args: {
project: {
type: "positional",
description: "Project name",
required: true,
},
requirements: {
type: "string",
alias: "r",
description: "Requirements / task description",
default: "",
},
},
async run({ args }) {
loadEnv();
try {
const { pipelineId, state } = await createPipeline(
args.project,
args.requirements ?? "",
);
// eslint-disable-next-line no-console -- CLI output
console.log(`Pipeline created: ${pipelineId}`);
// eslint-disable-next-line no-console
console.log(` project: ${args.project}`);
// eslint-disable-next-line no-console
console.log(` state: ${state}`);
} finally {
await disconnectPrisma();
}
},
});

87
src/cli/status.ts Normal file
View File

@@ -0,0 +1,87 @@
import { defineCommand } from "citty";
import { loadEnv } from "../env.js";
import {
getPipelineState,
listPipelines,
disconnectPrisma,
} from "../orchestrator/persist.js";
export default defineCommand({
meta: {
name: "status",
description: "Show pipeline status",
},
args: {
id: {
type: "positional",
description: "Pipeline ID (omit to list all)",
required: false,
},
},
async run({ args }) {
loadEnv();
try {
if (args.id) {
const result = await getPipelineState(args.id);
if (!result) {
// eslint-disable-next-line no-console
console.error(`Pipeline not found: ${args.id}`);
process.exitCode = 1;
return;
}
// eslint-disable-next-line no-console
console.log(`Pipeline: ${args.id}`);
// eslint-disable-next-line no-console
console.log(` project: ${result.context.projectName}`);
// eslint-disable-next-line no-console
console.log(` state: ${result.state}`);
// eslint-disable-next-line no-console
console.log(` sprint: ${result.context.currentSprintId ?? "(none)"}`);
// eslint-disable-next-line no-console
console.log(` retryCount: ${result.context.retryCount}`);
// eslint-disable-next-line no-console
console.log(` reviewRound: ${result.context.reviewRound}`);
// eslint-disable-next-line no-console
console.log(` lastError: ${result.context.lastError ?? "(none)"}`);
// eslint-disable-next-line no-console
console.log(` created: ${result.context.createdAt}`);
// eslint-disable-next-line no-console
console.log(` transitions: ${result.transitions.length}`);
if (result.transitions.length > 0) {
// eslint-disable-next-line no-console
console.log("\n Timeline:");
for (const t of result.transitions.slice(-10)) {
// eslint-disable-next-line no-console
console.log(
` ${t.timestamp.toISOString()} ${t.fromState}${t.toState} [${t.eventType}]`,
);
}
}
} else {
const pipelines = await listPipelines({ limit: 20 });
if (pipelines.length === 0) {
// eslint-disable-next-line no-console
console.log("No pipelines found.");
return;
}
// eslint-disable-next-line no-console
console.log(
`${"ID".padEnd(28)} ${"PROJECT".padEnd(20)} ${"STATE".padEnd(14)} CREATED`,
);
// eslint-disable-next-line no-console
console.log("-".repeat(80));
for (const p of pipelines) {
// eslint-disable-next-line no-console
console.log(
`${p.id.padEnd(28)} ${p.projectName.padEnd(20)} ${p.currentState.padEnd(14)} ${p.createdAt.toISOString()}`,
);
}
}
} finally {
await disconnectPrisma();
}
},
});

81
src/enforcement/guard.ts Normal file
View File

@@ -0,0 +1,81 @@
import {
readSkillContext,
isContextExpired,
contextAgeSeconds,
type SkillContext,
} from "./skill-context.js";
import { appendTrace } from "./skill-trace.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "guard" });
export interface GuardResult {
allowed: boolean;
reason: string;
context: SkillContext | null;
}
/**
* Check whether the current operation is allowed based on skill context.
* Used by pre-tool hook to gate Write/Edit/Bash calls.
*/
export async function checkGuard(
railsDir: string,
toolName: string,
opts?: { sessionId?: string },
): Promise<GuardResult> {
// Escape hatch
if (process.env["RAILS_ENFORCE"] === "off") {
log.warn({ toolName }, "Enforcement disabled via RAILS_ENFORCE=off");
await appendTrace(railsDir, {
ts: Date.now(),
tool: toolName,
sessionId: opts?.sessionId ?? "",
blocked: false,
reason: "enforcement-off",
});
return { allowed: true, reason: "enforcement-off", context: null };
}
const ctx = await readSkillContext(railsDir);
if (!ctx) {
const reason = "No skill context found. Run /rails or rails skill-context create first.";
log.warn({ toolName }, reason);
await appendTrace(railsDir, {
ts: Date.now(),
tool: toolName,
sessionId: opts?.sessionId ?? "",
blocked: true,
reason: "no-context",
});
return { allowed: false, reason, context: null };
}
if (isContextExpired(ctx)) {
const age = contextAgeSeconds(ctx);
const reason = `Skill context expired (age: ${age}s, ttl: ${ctx.ttlSeconds}s). Re-enter the skill.`;
log.warn({ toolName, age, ttl: ctx.ttlSeconds }, reason);
await appendTrace(railsDir, {
ts: Date.now(),
tool: toolName,
sessionId: opts?.sessionId ?? "",
pipelineId: ctx.pipelineId,
blocked: true,
reason: "context-expired",
});
return { allowed: false, reason, context: ctx };
}
// Valid context
await appendTrace(railsDir, {
ts: Date.now(),
tool: toolName,
sessionId: opts?.sessionId ?? "",
pipelineId: ctx.pipelineId,
blocked: false,
reason: "ok",
});
return { allowed: true, reason: "ok", context: ctx };
}

View File

@@ -0,0 +1,81 @@
import { z } from "zod";
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
import { dirname, join } from "node:path";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "skill-context" });
export const SkillContext = z.object({
skillName: z.string(),
subcommand: z.string().default(""),
pipelineId: z.string().default(""),
contractId: z.string().default(""),
sessionId: z.string().default(""),
createdAt: z.string().datetime(),
ttlSeconds: z.number().int().positive().default(300),
});
export type SkillContext = z.infer<typeof SkillContext>;
const CONTEXT_FILENAME = "skill-context.json";
function contextPath(railsDir: string): string {
return join(railsDir, ".rails", CONTEXT_FILENAME);
}
export async function createSkillContext(
railsDir: string,
data: {
skillName: string;
subcommand?: string;
pipelineId?: string;
contractId?: string;
sessionId?: string;
ttlSeconds?: number;
},
): Promise<SkillContext> {
const parsed = SkillContext.parse({
...data,
createdAt: new Date().toISOString(),
});
const filePath = contextPath(railsDir);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(parsed, null, 2), "utf8");
log.info({ skillName: parsed.skillName, pipelineId: parsed.pipelineId }, "Skill context created");
return parsed;
}
export async function readSkillContext(
railsDir: string,
): Promise<SkillContext | null> {
try {
const raw = await readFile(contextPath(railsDir), "utf8");
return SkillContext.parse(JSON.parse(raw));
} catch {
return null;
}
}
export async function clearSkillContext(railsDir: string): Promise<boolean> {
try {
await unlink(contextPath(railsDir));
log.info("Skill context cleared");
return true;
} catch {
return false;
}
}
export function isContextExpired(ctx: SkillContext): boolean {
const createdMs = new Date(ctx.createdAt).getTime();
const nowMs = Date.now();
const elapsedSeconds = (nowMs - createdMs) / 1000;
return elapsedSeconds > ctx.ttlSeconds;
}
export function contextAgeSeconds(ctx: SkillContext): number {
const createdMs = new Date(ctx.createdAt).getTime();
return Math.floor((Date.now() - createdMs) / 1000);
}

View File

@@ -0,0 +1,75 @@
import { z } from "zod";
import { appendFile, readFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "skill-trace" });
export const TraceEntry = z.object({
ts: z.number(),
tool: z.string(),
cwd: z.string().default(""),
sessionId: z.string().default(""),
pipelineId: z.string().default(""),
blocked: z.boolean().default(false),
reason: z.string().default(""),
});
export type TraceEntry = z.infer<typeof TraceEntry>;
const TRACE_FILENAME = "skill-trace.jsonl";
function tracePath(railsDir: string): string {
return join(railsDir, ".rails", TRACE_FILENAME);
}
export async function appendTrace(
railsDir: string,
entry: {
ts: number;
tool: string;
cwd?: string;
sessionId?: string;
pipelineId?: string;
blocked: boolean;
reason?: string;
},
): Promise<void> {
const filePath = tracePath(railsDir);
await mkdir(dirname(filePath), { recursive: true });
const parsed = TraceEntry.parse(entry);
await appendFile(filePath, JSON.stringify(parsed) + "\n", "utf8");
if (parsed.blocked) {
log.warn({ tool: parsed.tool, reason: parsed.reason }, "Tool call blocked");
}
}
export async function readTrace(
railsDir: string,
opts?: { pipelineId?: string; limit?: number },
): Promise<TraceEntry[]> {
try {
const raw = await readFile(tracePath(railsDir), "utf8");
const lines = raw.trim().split("\n").filter(Boolean);
let entries = lines.map((line) => TraceEntry.parse(JSON.parse(line)));
if (opts?.pipelineId) {
entries = entries.filter((e) => e.pipelineId === opts.pipelineId);
}
if (opts?.limit) {
entries = entries.slice(-opts.limit);
}
return entries;
} catch {
return [];
}
}
export async function countBlocked(railsDir: string): Promise<number> {
const entries = await readTrace(railsDir);
return entries.filter((e) => e.blocked).length;
}

37
src/env.ts Normal file
View File

@@ -0,0 +1,37 @@
import { z } from "zod";
const EnvSchema = z.object({
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
DISCORD_TOKEN: z.string().default(""),
DISCORD_GUILD_ID: z.string().default(""),
GITEA_WEBHOOK_SECRET: z.string().default(""),
RAILS_PORT: z.coerce.number().int().positive().default(18800),
RAILS_LOG_LEVEL: z
.enum(["silent", "fatal", "error", "warn", "info", "debug", "trace"])
.default("info"),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
});
export type Env = z.infer<typeof EnvSchema>;
let _env: Env | undefined;
export function loadEnv(): Env {
if (_env) return _env;
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
const formatted = result.error.issues
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Environment validation failed:\n${formatted}`);
}
_env = result.data;
return _env;
}
export function getEnv(): Env {
if (!_env) return loadEnv();
return _env;
}

37
src/logger.ts Normal file
View File

@@ -0,0 +1,37 @@
import pino from "pino";
let _logger: pino.Logger | undefined;
export function createLogger(opts?: {
level?: string;
pipelineId?: string;
}): pino.Logger {
const level = opts?.level ?? process.env["RAILS_LOG_LEVEL"] ?? "info";
const isDev = process.env["NODE_ENV"] !== "production";
const logger = pino({
level,
...(isDev && {
transport: { target: "pino-pretty", options: { colorize: true } },
}),
base: {
service: "hanarang-rails",
...(opts?.pipelineId && { pipelineId: opts.pipelineId }),
},
});
return logger;
}
export function getLogger(): pino.Logger {
if (!_logger) {
_logger = createLogger();
}
return _logger;
}
export function childLogger(
bindings: Record<string, unknown>,
): pino.Logger {
return getLogger().child(bindings);
}

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
export const PipelineContext = z.object({
pipelineId: z.string().min(1),
projectName: z.string(),
requirements: z.string().default(""),
currentSprintId: z.string().nullable().default(null),
reviewRound: z.number().int().min(0).default(0),
retryCount: z.number().int().min(0).default(0),
maxRetries: z.number().int().positive().default(3),
maxReviewRounds: z.number().int().positive().default(3),
lastError: z.string().nullable().default(null),
contractPath: z.string().nullable().default(null),
createdAt: z.string().datetime(),
});
export type PipelineContext = z.infer<typeof PipelineContext>;
export function createInitialContext(
pipelineId: string,
projectName: string,
requirements: string,
): PipelineContext {
return {
pipelineId,
projectName,
requirements,
currentSprintId: null,
reviewRound: 0,
retryCount: 0,
maxRetries: 3,
maxReviewRounds: 3,
lastError: null,
contractPath: null,
createdAt: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,76 @@
import { z } from "zod";
export const AgentName = z.string().min(1);
export type AgentName = z.infer<typeof AgentName>;
export const ReviewIssue = z.object({
severity: z.enum(["critical", "major", "minor", "recommendation"]),
message: z.string(),
file: z.string().optional(),
line: z.number().optional(),
});
export type ReviewIssue = z.infer<typeof ReviewIssue>;
export const PipelineEvent = z.discriminatedUnion("type", [
z.object({
type: z.literal("REQUEST"),
projectName: z.string(),
requirements: z.string(),
}),
z.object({
type: z.literal("PLAN_READY"),
planDir: z.string(),
sprintId: z.string(),
}),
z.object({
type: z.literal("IMPL_DONE"),
branch: z.string(),
commits: z.array(z.string()),
}),
z.object({
type: z.literal("APPROVE"),
reviewArtifact: z.string(),
}),
z.object({
type: z.literal("REQUEST_CHANGES"),
issues: z.array(ReviewIssue),
}),
z.object({
type: z.literal("DEPLOY_DONE"),
deployArtifact: z.string(),
}),
z.object({
type: z.literal("ERROR"),
actor: AgentName,
reason: z.string(),
retryable: z.boolean(),
}),
z.object({
type: z.literal("TIMEOUT"),
actor: AgentName,
elapsedMs: z.number(),
}),
z.object({ type: z.literal("RETRY") }),
z.object({ type: z.literal("RESUME") }),
z.object({
type: z.literal("ABORT"),
reason: z.string(),
}),
]);
export type PipelineEvent = z.infer<typeof PipelineEvent>;
export type PipelineEventType = PipelineEvent["type"];
export const PIPELINE_STATES = [
"idle",
"planning",
"implementing",
"reviewing",
"deploying",
"retrying",
"escalated",
"done",
"aborted",
] as const;
export type PipelineState = (typeof PIPELINE_STATES)[number];

248
src/orchestrator/machine.ts Normal file
View File

@@ -0,0 +1,248 @@
import { setup, assign } from "xstate";
import type { PipelineContext } from "./context.js";
import type { PipelineEvent } from "./events.js";
/**
* Deterministic pipeline state machine.
*
* States: idle → planning → implementing → reviewing → deploying → done
* Guards: retryCount < maxRetries, reviewRound <= maxReviewRounds
* Errors: retryable → retrying → prev state | non-retryable → escalated
*/
export const pipelineMachine = setup({
types: {
context: {} as PipelineContext,
events: {} as PipelineEvent,
},
guards: {
canRetry: ({ context }: { context: PipelineContext }) =>
context.retryCount < context.maxRetries,
canReviewAgain: ({ context }: { context: PipelineContext }) =>
context.reviewRound < context.maxReviewRounds,
isRetryable: ({ event }: { event: PipelineEvent }) =>
event.type === "ERROR" && event.retryable === true,
},
actions: {
incrementRetry: assign({
retryCount: ({ context }: { context: PipelineContext }) =>
context.retryCount + 1,
}),
resetRetry: assign({ retryCount: 0 }),
incrementReviewRound: assign({
reviewRound: ({ context }: { context: PipelineContext }) =>
context.reviewRound + 1,
}),
resetReviewRound: assign({ reviewRound: 0 }),
setError: assign({
lastError: ({ event }: { event: PipelineEvent }) =>
event.type === "ERROR" ? event.reason : null,
}),
clearError: assign({ lastError: null }),
setSprintId: assign({
currentSprintId: ({ event }: { event: PipelineEvent }) =>
event.type === "PLAN_READY" ? event.sprintId : null,
}),
setAbortReason: assign({
lastError: ({ event }: { event: PipelineEvent }) =>
event.type === "ABORT" ? event.reason : null,
}),
},
}).createMachine({
id: "pipeline",
initial: "idle",
context: ({}) => ({
pipelineId: "",
projectName: "",
requirements: "",
currentSprintId: null,
reviewRound: 0,
retryCount: 0,
maxRetries: 3,
maxReviewRounds: 3,
lastError: null,
contractPath: null,
createdAt: new Date().toISOString(),
}),
states: {
idle: {
on: {
REQUEST: {
target: "planning",
actions: [
"clearError",
"resetRetry",
assign({
projectName: ({ event }) => event.projectName,
requirements: ({ event }) => event.requirements,
}),
],
},
},
},
planning: {
on: {
PLAN_READY: {
target: "implementing",
actions: ["setSprintId", "resetRetry", "resetReviewRound"],
},
ERROR: [
{
guard: "isRetryable",
target: "retrying",
actions: ["setError", "incrementRetry"],
},
{
target: "escalated",
actions: ["setError"],
},
],
TIMEOUT: {
target: "retrying",
actions: ["incrementRetry"],
},
ABORT: {
target: "aborted",
actions: ["setAbortReason"],
},
},
},
implementing: {
on: {
IMPL_DONE: {
target: "reviewing",
actions: ["resetRetry"],
},
ERROR: [
{
guard: "isRetryable",
target: "retrying",
actions: ["setError", "incrementRetry"],
},
{
target: "escalated",
actions: ["setError"],
},
],
TIMEOUT: {
target: "retrying",
actions: ["incrementRetry"],
},
ABORT: {
target: "aborted",
actions: ["setAbortReason"],
},
},
},
reviewing: {
on: {
APPROVE: {
target: "deploying",
actions: ["resetRetry"],
},
REQUEST_CHANGES: [
{
guard: "canReviewAgain",
target: "implementing",
actions: ["incrementReviewRound"],
},
{
target: "escalated",
actions: [
assign({
lastError: "Max review rounds exceeded",
}),
],
},
],
ERROR: [
{
guard: "isRetryable",
target: "retrying",
actions: ["setError", "incrementRetry"],
},
{
target: "escalated",
actions: ["setError"],
},
],
TIMEOUT: {
target: "retrying",
actions: ["incrementRetry"],
},
ABORT: {
target: "aborted",
actions: ["setAbortReason"],
},
},
},
deploying: {
on: {
DEPLOY_DONE: {
target: "done",
},
ERROR: [
{
guard: "isRetryable",
target: "retrying",
actions: ["setError", "incrementRetry"],
},
{
target: "escalated",
actions: ["setError"],
},
],
TIMEOUT: {
target: "retrying",
actions: ["incrementRetry"],
},
ABORT: {
target: "aborted",
actions: ["setAbortReason"],
},
},
},
retrying: {
always: [
{
guard: "canRetry",
// For now, go back to idle; in Sprint 005 this will return
// to the previous state via history node.
target: "idle",
actions: ["clearError"],
},
{
target: "escalated",
actions: [
assign({ lastError: "Max retries exceeded" }),
],
},
],
},
escalated: {
on: {
RESUME: {
target: "idle",
actions: ["clearError", "resetRetry"],
},
ABORT: {
target: "aborted",
actions: ["setAbortReason"],
},
},
},
done: {
type: "final",
},
aborted: {
type: "final",
},
},
});

174
src/orchestrator/persist.ts Normal file
View File

@@ -0,0 +1,174 @@
import { PrismaClient } from "@prisma/client";
import { createActor, type Snapshot } from "xstate";
import { ulid } from "ulid";
import { pipelineMachine } from "./machine.js";
import { createInitialContext, type PipelineContext } from "./context.js";
import type { PipelineEvent, PipelineState } from "./events.js";
import { childLogger } from "../logger.js";
const log = childLogger({ module: "persist" });
let _prisma: PrismaClient | undefined;
export function getPrisma(): PrismaClient {
if (!_prisma) {
_prisma = new PrismaClient();
}
return _prisma;
}
export async function createPipeline(
projectName: string,
requirements: string,
): Promise<{ pipelineId: string; state: PipelineState }> {
const prisma = getPrisma();
const pipelineId = ulid();
const ctx = createInitialContext(pipelineId, projectName, requirements);
const actor = createActor(pipelineMachine, {
input: ctx,
});
actor.start();
const snapshot = actor.getSnapshot();
actor.stop();
await prisma.pipeline.create({
data: {
id: pipelineId,
projectName,
requirements,
currentState: String(snapshot.value),
contextJson: JSON.stringify(ctx),
},
});
log.info({ pipelineId, projectName }, "Pipeline created");
return { pipelineId, state: String(snapshot.value) as PipelineState };
}
export async function sendEvent(
pipelineId: string,
event: PipelineEvent,
): Promise<{ state: PipelineState; context: PipelineContext }> {
const prisma = getPrisma();
const pipeline = await prisma.pipeline.findUniqueOrThrow({
where: { id: pipelineId },
});
const ctx = JSON.parse(pipeline.contextJson) as PipelineContext;
const fromState = pipeline.currentState;
const actor = createActor(pipelineMachine, {
snapshot: {
value: fromState,
context: ctx,
} as unknown as Snapshot<unknown>,
});
actor.start();
actor.send(event);
const snapshot = actor.getSnapshot();
const toState = String(snapshot.value);
const newContext = snapshot.context as PipelineContext;
actor.stop();
await prisma.$transaction([
prisma.pipeline.update({
where: { id: pipelineId },
data: {
currentState: toState,
contextJson: JSON.stringify(newContext),
},
}),
prisma.stateTransition.create({
data: {
pipelineId,
fromState,
toState,
eventType: event.type,
eventPayload: JSON.stringify(event),
},
}),
]);
log.info(
{ pipelineId, fromState, toState, event: event.type },
"State transition",
);
return { state: toState as PipelineState, context: newContext };
}
export async function getPipelineState(
pipelineId: string,
): Promise<{
state: PipelineState;
context: PipelineContext;
transitions: Array<{
fromState: string;
toState: string;
eventType: string;
timestamp: Date;
}>;
} | null> {
const prisma = getPrisma();
const pipeline = await prisma.pipeline.findUnique({
where: { id: pipelineId },
include: {
transitions: {
orderBy: { timestamp: "asc" },
select: {
fromState: true,
toState: true,
eventType: true,
timestamp: true,
},
},
},
});
if (!pipeline) return null;
return {
state: pipeline.currentState as PipelineState,
context: JSON.parse(pipeline.contextJson) as PipelineContext,
transitions: pipeline.transitions,
};
}
export async function listPipelines(opts?: {
state?: PipelineState;
limit?: number;
}): Promise<
Array<{
id: string;
projectName: string;
currentState: string;
createdAt: Date;
updatedAt: Date;
}>
> {
const prisma = getPrisma();
return prisma.pipeline.findMany({
where: opts?.state ? { currentState: opts.state } : undefined,
orderBy: { createdAt: "desc" },
take: opts?.limit ?? 20,
select: {
id: true,
projectName: true,
currentState: true,
createdAt: true,
updatedAt: true,
},
});
}
export async function disconnectPrisma(): Promise<void> {
if (_prisma) {
await _prisma.$disconnect();
_prisma = undefined;
}
}

157
tests/enforcement.test.ts Normal file
View File

@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
createSkillContext,
readSkillContext,
clearSkillContext,
isContextExpired,
} from "../src/enforcement/skill-context.js";
import { appendTrace, readTrace, countBlocked } from "../src/enforcement/skill-trace.js";
import { checkGuard } from "../src/enforcement/guard.js";
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "rails-test-"));
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe("skill-context", () => {
it("creates and reads context", async () => {
const ctx = await createSkillContext(testDir, {
skillName: "rails",
pipelineId: "01TEST",
ttlSeconds: 300,
});
expect(ctx.skillName).toBe("rails");
expect(ctx.pipelineId).toBe("01TEST");
const read = await readSkillContext(testDir);
expect(read).not.toBeNull();
expect(read!.skillName).toBe("rails");
});
it("returns null when no context exists", async () => {
const read = await readSkillContext(testDir);
expect(read).toBeNull();
});
it("clears context", async () => {
await createSkillContext(testDir, { skillName: "rails", ttlSeconds: 300 });
const cleared = await clearSkillContext(testDir);
expect(cleared).toBe(true);
const read = await readSkillContext(testDir);
expect(read).toBeNull();
});
it("detects expired context", () => {
const ctx = {
skillName: "rails",
subcommand: "",
pipelineId: "",
contractId: "",
sessionId: "",
createdAt: new Date(Date.now() - 400_000).toISOString(), // 400s ago
ttlSeconds: 300,
};
expect(isContextExpired(ctx)).toBe(true);
});
it("detects valid context", () => {
const ctx = {
skillName: "rails",
subcommand: "",
pipelineId: "",
contractId: "",
sessionId: "",
createdAt: new Date().toISOString(),
ttlSeconds: 300,
};
expect(isContextExpired(ctx)).toBe(false);
});
});
describe("skill-trace", () => {
it("appends and reads trace entries", async () => {
await appendTrace(testDir, {
ts: Date.now(),
tool: "Write",
blocked: false,
reason: "ok",
});
await appendTrace(testDir, {
ts: Date.now(),
tool: "Bash",
blocked: true,
reason: "no-context",
});
const entries = await readTrace(testDir);
expect(entries).toHaveLength(2);
expect(entries[1]!.blocked).toBe(true);
});
it("counts blocked entries", async () => {
await appendTrace(testDir, { ts: Date.now(), tool: "Write", blocked: false, reason: "ok" });
await appendTrace(testDir, { ts: Date.now(), tool: "Edit", blocked: true, reason: "no-ctx" });
await appendTrace(testDir, { ts: Date.now(), tool: "Bash", blocked: true, reason: "expired" });
expect(await countBlocked(testDir)).toBe(2);
});
it("returns empty array when no trace file", async () => {
expect(await readTrace(testDir)).toEqual([]);
});
});
describe("guard", () => {
it("blocks when no context exists", async () => {
const result = await checkGuard(testDir, "Write");
expect(result.allowed).toBe(false);
expect(result.reason).toContain("No skill context");
});
it("allows when valid context exists", async () => {
await createSkillContext(testDir, {
skillName: "rails",
ttlSeconds: 300,
});
const result = await checkGuard(testDir, "Write");
expect(result.allowed).toBe(true);
expect(result.reason).toBe("ok");
});
it("blocks when context is expired", async () => {
await createSkillContext(testDir, {
skillName: "rails",
ttlSeconds: 1, // 1 second TTL
});
// Wait just over 1 second
await new Promise((r) => setTimeout(r, 1100));
const result = await checkGuard(testDir, "Edit");
expect(result.allowed).toBe(false);
expect(result.reason).toContain("expired");
});
it("allows when RAILS_ENFORCE=off", async () => {
process.env["RAILS_ENFORCE"] = "off";
try {
const result = await checkGuard(testDir, "Bash");
expect(result.allowed).toBe(true);
expect(result.reason).toBe("enforcement-off");
} finally {
delete process.env["RAILS_ENFORCE"];
}
});
it("records blocked calls in trace", async () => {
await checkGuard(testDir, "Write");
const entries = await readTrace(testDir);
expect(entries.some((e) => e.blocked && e.tool === "Write")).toBe(true);
});
});

113
tests/machine.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import { createActor } from "xstate";
import { pipelineMachine } from "../src/orchestrator/machine.js";
function runMachine(events: Array<Record<string, unknown>>) {
const actor = createActor(pipelineMachine);
actor.start();
for (const event of events) {
actor.send(event as any);
}
const snapshot = actor.getSnapshot();
actor.stop();
return snapshot;
}
describe("pipelineMachine", () => {
it("starts in idle", () => {
const actor = createActor(pipelineMachine);
actor.start();
expect(actor.getSnapshot().value).toBe("idle");
actor.stop();
});
it("happy path: idle → planning → implementing → reviewing → deploying → done", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "build something" },
{ type: "PLAN_READY", planDir: "/tmp/plans", sprintId: "SPRINT-001" },
{ type: "IMPL_DONE", branch: "feature/sprint-001", commits: ["abc1234"] },
{ type: "APPROVE", reviewArtifact: "/tmp/review.json" },
{ type: "DEPLOY_DONE", deployArtifact: "/tmp/deploy.json" },
]);
expect(snapshot.value).toBe("done");
expect(snapshot.status).toBe("done");
});
it("REQUEST_CHANGES loops back to implementing (up to maxReviewRounds)", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
{ type: "IMPL_DONE", branch: "b", commits: ["c1"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix it" }] },
]);
expect(snapshot.value).toBe("implementing");
expect(snapshot.context.reviewRound).toBe(1);
});
it("escalates after max review rounds exceeded", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "PLAN_READY", planDir: "/tmp", sprintId: "S1" },
// Round 1 (reviewRound: 0 → 1)
{ type: "IMPL_DONE", branch: "b", commits: ["c1"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
// Round 2 (reviewRound: 1 → 2)
{ type: "IMPL_DONE", branch: "b", commits: ["c2"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
// Round 3 (reviewRound: 2 → 3)
{ type: "IMPL_DONE", branch: "b", commits: ["c3"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
// Round 4 — reviewRound=3, guard 3 < 3 = false → escalated
{ type: "IMPL_DONE", branch: "b", commits: ["c4"] },
{ type: "REQUEST_CHANGES", issues: [{ severity: "major", message: "fix" }] },
]);
expect(snapshot.value).toBe("escalated");
expect(snapshot.context.lastError).toContain("review rounds");
});
it("retryable error goes to retrying, then back (if under limit)", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "ERROR", actor: "planner", reason: "timeout", retryable: true },
]);
// retrying has an always transition — if canRetry, goes to idle
expect(snapshot.value).toBe("idle");
expect(snapshot.context.retryCount).toBe(1);
});
it("non-retryable error goes to escalated", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "ERROR", actor: "planner", reason: "permission denied", retryable: false },
]);
expect(snapshot.value).toBe("escalated");
expect(snapshot.context.lastError).toBe("permission denied");
});
it("escalated → RESUME goes back to idle", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "ERROR", actor: "planner", reason: "fail", retryable: false },
{ type: "RESUME" },
]);
expect(snapshot.value).toBe("idle");
expect(snapshot.context.lastError).toBeNull();
});
it("ABORT from any active state goes to aborted", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "test", requirements: "" },
{ type: "ABORT", reason: "user cancelled" },
]);
expect(snapshot.value).toBe("aborted");
expect(snapshot.context.lastError).toBe("user cancelled");
});
it("context tracks projectName and requirements from REQUEST", () => {
const snapshot = runMachine([
{ type: "REQUEST", projectName: "arang", requirements: "Live2D avatar" },
]);
expect(snapshot.context.projectName).toBe("arang");
expect(snapshot.context.requirements).toBe("Live2D avatar");
});
});

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": false,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}

10
vitest.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
testTimeout: 10_000,
},
});