Capstone Hands-on Lab C / Chapter 1-5 통합

Mission: Grand Open, 말로 주문받는 AI 카페를 개점하라

Claude Code Deep Dive Workshop, Capstone C - 고객 서비스 풀스택 (135분 목표 / 150분 상한)

이번 미션의 정체성은 "파는 Claude", 고객 접점 제품입니다. 손님이 자연어로 주문하면("아아 두 잔인데 하나는 샷 추가요") Bedrock이 구조화 주문으로 접수하고, 주방 디스플레이에 티켓이 실시간으로 흐르는 3-tier 카페를 개점합니다. 새로 익히는 것은 Converse의 toolConfig 구조화 추출, 그리고 "AI 출력을 믿지 말고 다시 검증하는" 영수증 로직입니다. 피날레는 변화구 주문 카드 6장으로 여러분의 접수원을 시험하는 개점 스트레스 테스트입니다.

환경 Claude Code on EC2 + Admin 계정 리전 ap-northeast-2 모델 Sonnet 4.6 (Global CRIS) Update 2026.07
MISSION BRIEFING

홀, 주방, 그리고 접수원

3-tier 구성입니다. 프론트는 고객 주문 페이지와 주방 디스플레이 2뷰(S3 + CloudFront), 백엔드는 주문/보드 Lambda 2종(HTTP API), DB는 메뉴와 주문을 담는 DynamoDB 단일 테이블. AI 접수원은 주문 Lambda 안의 Bedrock Converse 호출로, toolChoice로 강제된 place_order 도구만으로 응답해 자연어를 스키마에 가둡니다. 그리고 그 출력조차 서버의 영수증 로직이 메뉴판과 대조해 다시 검증합니다.

아키텍처
손님 브라우저 ── CloudFront ── S3 ── index.html   (자연어 주문 + 영수증)
주방 브라우저 ──────┘              └ kitchen.html (NEW/MAKING/DONE 보드, 3초 폴링)
        │
        └ fetch ── HTTP API ─┬─ Lambda order ─┬─ Bedrock Converse + toolConfig
                             │                │   (place_order 강제, Sonnet 4.6)
                             │                └─ receipt.mjs 재검증 ── DynamoDB (menu + orders)
                             └─ Lambda board ─── 주문 조회 / 상태 전이 / 매출 집계
규칙 1 - 코드는 Claude에게 시킨다, AI의 출력은 서버가 다시 검증한다 규칙 2 - 각 미션은 Definition of Done으로만 판정한다 규칙 3 - 막히면 "막힐 때 열어보기" 완성본으로 복구하고 전진한다

A 또는 B를 마친 계정이라면 M0은 확인 위주로 짧아집니다. 90분 시점에 M3 미착수면 부록 컷라인을 적용하세요.

MISSION 00

발사 준비, 계획 승인까지

15분

스캐폴드와 superpowers를 준비하고 4-Phase 계획을 승인합니다.

1점검과 스캐폴드
Terminal, 전체 복사
aws sts get-caller-identity --query Account --output text
node -v && jq --version && npx cdk --version

Bedrock 호출(주문 접수원)은 다른 계정에서 발급받은 API 키(Bearer)로 인증합니다. CapstoneA를 거쳤다면 ~/capstone/.env가 이미 있으니 1/3은 건너뛰고 2/3부터 하세요.

Terminal 1/3, .env에 Bearer 토큰 저장
mkdir -p ~/capstone
cat > ~/capstone/.env << 'ENVEOF'
AWS_BEARER_TOKEN_BEDROCK=<발급받은 Bedrock API 키>
ENVEOF
Terminal 2/3, 셸에 로드
set -a; source ~/capstone/.env; set +a
Terminal 3/3, Converse 스모크
aws bedrock-runtime converse \
  --region ap-northeast-2 \
  --model-id global.anthropic.claude-sonnet-4-6 \
  --messages '[{"role":"user","content":[{"text":"ping"}]}]' \
  --inference-config '{"maxTokens":30}' \
  --query 'output.message.content[0].text' --output text
Terminal, 스캐폴드 (재실행 안전)
mkdir -p ~/capstone/ai-cafe && cd ~/capstone/ai-cafe
[ -f cdk.json ] || npx cdk init app --language typescript
(npx cdk bootstrap > /tmp/bootstrap.log 2>&1 &)   # 기부트스트랩 계정은 수 초 내 통과
grep -qxF '.env' .gitignore 2>/dev/null || echo '.env' >> .gitignore
git config user.name "capstone" && git config user.email "capstone@lab.local"
git add -A && git commit -m "chore: cdk scaffold" 2>/dev/null || true

superpowers는 /plugin으로 확인, 없으면 /plugin install superpowers@claude-plugins-official 후 세션 재시작입니다.

2브레인스토밍과 계획
Claude 세션 입력, 전체 복사
/superpowers:brainstorming 다음 요구사항으로 "AI 카페 주문 서비스"를 설계하자.
1) DB: DynamoDB 단일 테이블(pk/sk). 메뉴는 pk=menu(sk=메뉴id, name/price/options 맵),
   주문은 pk=order(sk=시각#id, customer/lines/total/status/note/source)
2) 백엔드 Lambda 2종(Node 22) + HTTP API(CORS):
   - order: POST /order 가 {items:[...]} 직접 주문과 {text:"자연어"} 겸용.
     text면 Bedrock Converse를 toolConfig(place_order 도구, toolChoice 강제)로 호출해
     items와 reply를 구조화 추출. 추출 결과는 receipt 모듈이 메뉴판과 대조해 재검증
     (없는 메뉴/옵션 거절, 수량 1~10 클램프, 합계 계산). 모델은 env ORDER_MODEL_ID,
     기본 global.anthropic.claude-sonnet-4-6, maxTokens 700
   - board: GET /orders(최근 30건+매출 합계), POST /status(NEW/MAKING/DONE 전이), GET /menu
3) 프론트 2뷰(S3+CloudFront): index.html 고객 주문(메뉴판, 자연어 입력, 영수증 렌더),
   kitchen.html 주방 보드(3열 상태 컬럼, 3초 폴링, 상태 전이 버튼, 매출 표시)
4) CDK TypeScript 단일 스택(CafeStack), ap-northeast-2, DESTROY,
   Outputs: ApiUrl/SiteUrl/KitchenUrl/TableName
5) 구현 순서 Phase 1(DB+백엔드, 직접 주문 경로까지) → Phase 2(프론트 2뷰) →
   Phase 3(AI 자연어 접수) → Phase 4(러시 시뮬레이터와 개점 스킬)
6) 시간 제약 2시간: 단위 테스트는 receipt 모듈만, 나머지는 smoke curl로 갈음
7) Bedrock 인증은 IAM이 아니라 발급받은 API 키를 쓴다: 배포 셸의 AWS_BEARER_TOKEN_BEDROCK 값을
   CDK가 Lambda 환경변수로 주입하고, Bedrock용 IAM 정책은 만들지 않는다
결정이 필요하면 이 범위 안에서 최소한으로만 물어봐.
자동화 옵션, auto mode 세션에 auto mode on을 선언해 두면 superpowers가 brainstorming → write-plan → execute-plan의 단계 전환과 승인 대기를 자동으로 이어 갑니다. 시간이 빠듯할 때 유용합니다. 단계마다 직접 검토하려면 기본(수동)을 유지하고, 되돌리려면 auto mode off입니다.

치트시트: 인증 없음(데모 카페), 결제 없음(영수증까지만), 다크 테마 + 오렌지 포인트. 계획이 나오면 Phase 4개 구성을 확인하고 승인하세요.

worktree 순간이동 superpowers는 계획 후 git worktree로 이동합니다. deploy와 seed, rush는 전부 pwd로 확인한 그 경로에서 실행하세요.
DEFINITION OF DONE
MISSION 01

백엔드 코어, 메뉴 입고와 첫 주문

30분

Phase 1을 배포하고 메뉴를 시딩한 뒤, AI 없이 직접 주문 경로로 주문 저장 → 보드 조회 → 상태 전이의 전 과정을 curl로 왕복합니다.

1Phase 1 실행과 배포
Claude 세션 입력
/superpowers:execute-plan Phase 1만 구현하고 멈춰줘. receipt 모듈에는 요구사항의 단위 테스트(합계, 거절, 클램프)를 포함하고, 끝나면 npx cdk synth --quiet 검증까지 보고해.
Terminal, worktree에서
set -a; source ~/capstone/.env; set +a   # Bearer 키 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
API=$(jq -r '.CafeStack.ApiUrl' cdk-outputs.json) && export CAFE_API="$API" OPS_OUTPUTS="$PWD/cdk-outputs.json"
2메뉴 시딩
Terminal, 전체 복사 (worktree에서)
cat > seed.sh << 'SEEDEOF'
#!/bin/bash
# 메뉴 시딩. 사용: ./seed.sh
set -euo pipefail
OUTS="${OPS_OUTPUTS:-cdk-outputs.json}"
TABLE=$(jq -r '.CafeStack.TableName' "$OUTS")

aws dynamodb batch-write-item --request-items "$(jq -n --arg t "$TABLE" '{
  ($t): [
    {"PutRequest":{"Item":{
      "pk":{"S":"menu"},"sk":{"S":"americano-ice"},
      "name":{"S":"아이스 아메리카노"},"price":{"N":"4500"},
      "options":{"M":{"shot":{"N":"500"}}}}}},
    {"PutRequest":{"Item":{
      "pk":{"S":"menu"},"sk":{"S":"latte"},
      "name":{"S":"라떼"},"price":{"N":"5000"},
      "options":{"M":{"shot":{"N":"500"},"decaf":{"N":"0"}}}}}},
    {"PutRequest":{"Item":{
      "pk":{"S":"menu"},"sk":{"S":"croissant"},
      "name":{"S":"크루아상"},"price":{"N":"3800"},
      "options":{"M":{}}}}}
  ]}')"
echo "[seed] 메뉴 3종 입고 완료: 아이스 아메리카노 4500 / 라떼 5000 / 크루아상 3800"
SEEDEOF
chmod +x seed.sh
./seed.sh
curl -s "$API/menu" | jq '.menu | length'
3직접 주문 왕복 (AI 없이 배관 검증)
Terminal, 전체 복사
ORDER=$(curl -s -X POST "$API/order" -H 'Content-Type: application/json' \
  -d '{"items":[{"menu_id":"americano-ice","qty":2,"options":["shot"]},{"menu_id":"croissant","qty":1}],"customer":"tester"}')
echo "$ORDER" | jq '{order_id, total: .receipt.total}'
ID=$(echo "$ORDER" | jq -r '.order_id')

curl -s "$API/orders" | jq '{sales, first: .orders[0].status}'
curl -s -X POST "$API/status" -H 'Content-Type: application/json' -d "{\"id\":\"$ID\",\"status\":\"MAKING\"}"
curl -s "$API/orders" | jq '.orders[0].status'
출력 예시
{ "order_id": "1753224001-a1b2", "total": 13800 }
{ "sales": 13800, "first": "NEW" }
{"ok":true}
"MAKING"
막힐 때 열어보기, 완성본 (스택 + Lambda 3파일, M3까지 반영된 최종형)

뒤처졌다면 아래 5개 파일과 M2의 프론트 2파일을 모두 배치하고 한 번에 deploy해 복구하세요. order.mjs의 자연어 경로는 M3에서 활성화합니다.

lib/cafe-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2";
import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";

export class CafeStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const orderModelId = this.node.tryGetContext("orderModelId") ?? "global.anthropic.claude-sonnet-4-6";

    // ── Phase 1: 저장소와 주문/보드 API ──
    const table = new dynamodb.Table(this, "CafeTable", {
      partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
      sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    const orderFn = new lambda.Function(this, "OrderFn", {
      runtime: lambda.Runtime.NODEJS_22_X,
      handler: "order.handler",
      code: lambda.Code.fromAsset("lambda"),
      environment: {
        TABLE_NAME: table.tableName,
        ORDER_MODEL_ID: orderModelId,
        // 발급 API 키 인증, 배포 셸에서 주입 (Bedrock IAM 정책 불필요)
        AWS_BEARER_TOKEN_BEDROCK: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "",
      },
      timeout: cdk.Duration.seconds(29),
      memorySize: 512,
    });
    table.grantReadWriteData(orderFn);

    const boardFn = new lambda.Function(this, "BoardFn", {
      runtime: lambda.Runtime.NODEJS_22_X,
      handler: "board.handler",
      code: lambda.Code.fromAsset("lambda"),
      environment: { TABLE_NAME: table.tableName },
      timeout: cdk.Duration.seconds(10),
    });
    table.grantReadWriteData(boardFn);

    const api = new apigwv2.HttpApi(this, "CafeApi", {
      corsPreflight: {
        allowOrigins: ["*"],
        allowMethods: [apigwv2.CorsHttpMethod.GET, apigwv2.CorsHttpMethod.POST, apigwv2.CorsHttpMethod.OPTIONS],
        allowHeaders: ["Content-Type"],
      },
    });
    const orderInt = new integrations.HttpLambdaIntegration("OrderInt", orderFn);
    const boardInt = new integrations.HttpLambdaIntegration("BoardInt", boardFn);
    api.addRoutes({ path: "/order", methods: [apigwv2.HttpMethod.POST], integration: orderInt });
    api.addRoutes({ path: "/orders", methods: [apigwv2.HttpMethod.GET], integration: boardInt });
    api.addRoutes({ path: "/status", methods: [apigwv2.HttpMethod.POST], integration: boardInt });
    api.addRoutes({ path: "/menu", methods: [apigwv2.HttpMethod.GET], integration: boardInt });

    // ── Phase 2: 프론트 2뷰 (S3 + CloudFront) ──
    const siteBucket = new s3.Bucket(this, "SiteBucket", {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });
    const dist = new cloudfront.Distribution(this, "SiteDist", {
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      defaultRootObject: "index.html",
    });
    new s3deploy.BucketDeployment(this, "DeploySite", {
      sources: [s3deploy.Source.asset("frontend")],
      destinationBucket: siteBucket,
      distribution: dist,
      distributionPaths: ["/*"],
    });

    new cdk.CfnOutput(this, "ApiUrl", { value: api.apiEndpoint });
    new cdk.CfnOutput(this, "SiteUrl", { value: `https://${dist.distributionDomainName}` });
    new cdk.CfnOutput(this, "KitchenUrl", { value: `https://${dist.distributionDomainName}/kitchen.html` });
    new cdk.CfnOutput(this, "TableName", { value: table.tableName });
  }
}
bin/ai-cafe.ts (cdk init 산출물 교체)
import * as cdk from "aws-cdk-lib";
import { CafeStack } from "./cafe-stack";

const app = new cdk.App();
new CafeStack(app, "CafeStack", {
  env: { region: process.env.CDK_DEFAULT_REGION ?? "ap-northeast-2" },
  tags: { project: "capstone-ai-cafe" },
});
lambda/receipt.mjs
// 메뉴 대조 검증과 합계 계산. AI가 뽑은 주문을 신뢰하지 않고 여기서 다시 검증한다.
export function buildReceipt(menu, items) {
  const byId = Object.fromEntries(menu.map((m) => [m.id, m]));
  const lines = [];
  const rejected = [];
  let total = 0;

  for (const it of items ?? []) {
    const m = byId[it.menu_id];
    if (!m) {
      rejected.push(`unknown menu: ${it.menu_id}`);
      continue;
    }
    const qty = Math.min(Math.max(Number(it.qty) || 1, 1), 10);
    let unit = m.price;
    const opts = [];
    for (const o of it.options ?? []) {
      if (m.options && o in m.options) {
        unit += m.options[o];
        opts.push(o);
      } else {
        rejected.push(`unknown option: ${o} (${m.name})`);
      }
    }
    const subtotal = unit * qty;
    total += subtotal;
    lines.push({ name: m.name, qty, options: opts, subtotal });
  }
  return { lines, total, rejected };
}
lambda/order.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
import { buildReceipt } from "./receipt.mjs";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const brt = new BedrockRuntimeClient({});
const TABLE = process.env.TABLE_NAME;
const MODEL_ID = process.env.ORDER_MODEL_ID ?? "global.anthropic.claude-sonnet-4-6";
const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "Content-Type",
  "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
};

async function loadMenu() {
  const res = await ddb.send(new QueryCommand({
    TableName: TABLE,
    KeyConditionExpression: "pk = :p",
    ExpressionAttributeValues: { ":p": "menu" },
  }));
  return (res.Items ?? []).map((x) => ({
    id: x.sk, name: x.name, price: x.price, options: x.options ?? {},
  }));
}

async function extractOrder(menu, text) {
  const res = await brt.send(new ConverseCommand({
    modelId: MODEL_ID,
    system: [{
      text: `당신은 카페 주문 접수원입니다. 메뉴판: ${JSON.stringify(menu)}. ` +
        "손님의 말을 place_order 도구 호출로만 응답하세요. " +
        "메뉴판에 없는 것은 items에 넣지 말고 reply에서 정중히 안내하세요. " +
        "reply는 한국어 한두 문장의 접수 멘트입니다.",
    }],
    messages: [{ role: "user", content: [{ text: String(text).slice(0, 1000) }] }],
    toolConfig: {
      tools: [{
        toolSpec: {
          name: "place_order",
          description: "구조화된 카페 주문 접수",
          inputSchema: {
            json: {
              type: "object",
              properties: {
                items: {
                  type: "array",
                  items: {
                    type: "object",
                    properties: {
                      menu_id: { type: "string" },
                      qty: { type: "integer" },
                      options: { type: "array", items: { type: "string" } },
                    },
                    required: ["menu_id", "qty"],
                  },
                },
                reply: { type: "string" },
              },
              required: ["items", "reply"],
            },
          },
        },
      }],
      toolChoice: { tool: { name: "place_order" } },
    },
    inferenceConfig: { maxTokens: 700 },
  }));
  const tu = res.output?.message?.content?.find((c) => c.toolUse)?.toolUse;
  if (!tu) throw new Error("no toolUse in response");
  return tu.input;
}

export const handler = async (event) => {
  const method = event.requestContext?.http?.method ?? "POST";
  if (method === "OPTIONS") return { statusCode: 204, headers: CORS };

  try {
    const body = JSON.parse(event.body ?? "{}");
    const menu = await loadMenu();
    if (!menu.length) {
      return { statusCode: 409, headers: CORS, body: JSON.stringify({ error: "메뉴가 비었습니다. seed.sh를 먼저 실행하세요." }) };
    }

    let items = body.items;
    let reply = "주문이 접수되었습니다.";
    if (!items && body.text) {
      const extracted = await extractOrder(menu, body.text);
      items = extracted.items;
      reply = extracted.reply;
    }
    if (!items) {
      return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "items 또는 text 필요" }) };
    }

    const receipt = buildReceipt(menu, items);
    if (!receipt.lines.length) {
      return { statusCode: 422, headers: CORS, body: JSON.stringify({ reply, receipt, error: "접수 가능한 항목 없음" }) };
    }

    const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
    await ddb.send(new PutCommand({
      TableName: TABLE,
      Item: {
        pk: "order", sk: id,
        customer: String(body.customer ?? "guest").slice(0, 30),
        source: body.text ? "ai" : "direct",
        note: String(body.text ?? "").slice(0, 200),
        lines: receipt.lines, total: receipt.total,
        status: "NEW", at: new Date().toISOString(),
      },
    }));
    return { statusCode: 201, headers: CORS, body: JSON.stringify({ order_id: id, reply, receipt }) };
  } catch (err) {
    console.error("ORDER_ERROR", err.name, err.message);
    return { statusCode: 502, headers: CORS, body: JSON.stringify({ error: err.name, message: err.message }) };
  }
};
lambda/board.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;
const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "Content-Type",
  "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
};
const FLOW = ["NEW", "MAKING", "DONE"];

export const handler = async (event) => {
  const method = event.requestContext?.http?.method ?? "GET";
  const path = event.rawPath ?? "/";
  if (method === "OPTIONS") return { statusCode: 204, headers: CORS };

  try {
    if (path.endsWith("/menu")) {
      const res = await ddb.send(new QueryCommand({
        TableName: TABLE,
        KeyConditionExpression: "pk = :p",
        ExpressionAttributeValues: { ":p": "menu" },
      }));
      return { statusCode: 200, headers: CORS, body: JSON.stringify({ menu: res.Items ?? [] }) };
    }

    if (path.endsWith("/status") && method === "POST") {
      const { id, status } = JSON.parse(event.body ?? "{}");
      if (!id || !FLOW.includes(status)) {
        return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "id와 status(NEW/MAKING/DONE) 필요" }) };
      }
      await ddb.send(new UpdateCommand({
        TableName: TABLE,
        Key: { pk: "order", sk: id },
        UpdateExpression: "SET #s = :s",
        ExpressionAttributeNames: { "#s": "status" },
        ExpressionAttributeValues: { ":s": status },
      }));
      return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true }) };
    }

    const res = await ddb.send(new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: "pk = :p",
      ExpressionAttributeValues: { ":p": "order" },
      ScanIndexForward: false,
      Limit: 30,
    }));
    const orders = res.Items ?? [];
    const sales = orders.reduce((a, x) => a + (x.total || 0), 0);
    return { statusCode: 200, headers: CORS, body: JSON.stringify({ orders, sales }) };
  } catch (err) {
    console.error(err);
    return { statusCode: 500, headers: CORS, body: JSON.stringify({ error: err.message }) };
  }
};
DEFINITION OF DONE
MISSION 02

프론트 2뷰, 홀과 주방을 연다

20분

서브에이전트 2개가 고객 페이지와 주방 보드를 병렬로 만드는 동안 Phase 2(S3+CloudFront)를 붙입니다. 창 두 개를 나란히 두면 벌써 가게 같습니다.

12뷰 병렬 빌드 지시
Claude 세션 입력, 전체 복사
서브에이전트 2개를 병렬로 사용해 frontend/ 아래 두 페이지를 만들어줘. 공통: 다크(#12181F)+오렌지(#FF9900), const API_BASE="PASTE_API_URL", fetch 실패는 조용히 무시.
[index.html 고객] GET /menu로 메뉴판(이름/옵션/가격) 렌더, 자연어 주문 textarea와 닉네임 입력,
POST /order {text, customer} 후 영수증 렌더(reply 멘트, 품목 표, 합계, rejected 경고, 주문번호)
[kitchen.html 주방] GET /orders 3초 폴링, NEW/MAKING/DONE 3열 티켓 보드(닉네임, 품목 x수량, 합계, 자연어 note),
NEW에는 "제조 시작", MAKING에는 "완성" 버튼으로 POST /status 전이, 헤더에 오늘 매출 표시
Claude 세션 입력 (이어서)
/superpowers:execute-plan Phase 2(S3 + CloudFront + BucketDeployment, frontend/ 소스)만 구현해줘.
2API 주입과 개장
Terminal, 전체 복사
sed -i "s|PASTE_API_URL|$API|" frontend/index.html frontend/kitchen.html
set -a; source ~/capstone/.env; set +a   # Bearer 키 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
jq -r '.CafeStack | "고객: \(.SiteUrl)\n주방: \(.KitchenUrl)"' cdk-outputs.json

브라우저 창 두 개를 나란히 여세요. 고객 페이지에 메뉴 3종이 보이고, M1에서 넣은 tester 주문 티켓이 주방 보드 MAKING 열에 있어야 합니다. "완성" 버튼으로 DONE까지 밀어 보세요. CloudFront 첫 전파 몇 분 동안은 M3를 먼저 진행합니다.

막힐 때 열어보기, 완성본 (프론트 2뷰)
frontend/index.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude Cafe</title>
<style>
  :root { --bg:#12181F; --card:#1B242E; --line:#2A3542; --text:#E8ECF1; --muted:#8B96A5; --accent:#FF9900; --green:#3FB950; }
  * { box-sizing:border-box; margin:0; }
  body { background:var(--bg); color:var(--text); font-family:system-ui,sans-serif; padding:28px 20px 60px; }
  .wrap { max-width:680px; margin:0 auto; }
  h1 { font-size:26px; } h1 em { color:var(--accent); font-style:normal; }
  .sub { color:var(--muted); font-size:13px; margin:4px 0 20px; }
  .panel { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px; margin-bottom:16px; }
  .panel h2 { font-size:13px; color:var(--muted); letter-spacing:.06em; margin-bottom:12px; }
  ul#menu { list-style:none; display:grid; gap:8px; }
  ul#menu li { display:flex; justify-content:space-between; font-size:14.5px; padding:8px 10px; background:#0C1117; border-radius:8px; }
  ul#menu .price { color:var(--accent); font-weight:700; }
  ul#menu .opt { color:var(--muted); font-size:12px; }
  textarea { width:100%; background:#0C1117; border:1px solid var(--line); border-radius:8px; color:var(--text); padding:12px; font-size:15px; min-height:74px; resize:vertical; }
  .row { display:flex; gap:10px; margin-top:10px; }
  input#nick { flex:1; background:#0C1117; border:1px solid var(--line); border-radius:8px; color:var(--text); padding:10px 12px; }
  button { background:var(--accent); border:0; border-radius:8px; color:#12181F; padding:10px 24px; font-weight:800; cursor:pointer; font-size:15px; }
  button:disabled { opacity:.5; }
  #receipt { font-size:14px; line-height:1.7; }
  #receipt .reply { color:var(--green); margin-bottom:8px; }
  #receipt table { width:100%; border-collapse:collapse; margin-top:6px; }
  #receipt td { padding:6px 4px; border-bottom:1px solid var(--line); }
  #receipt td.r { text-align:right; }
  #receipt .total td { color:var(--accent); font-weight:800; border-bottom:0; }
  #receipt .rej { color:#f08080; font-size:12.5px; margin-top:6px; }
  .empty { color:var(--muted); font-size:13px; }
</style>
</head>
<body>
<div class="wrap">
  <h1><em>Claude</em> Cafe</h1>
  <div class="sub">말로 주문하세요. AI 접수원이 알아듣습니다 - Capstone Lab C</div>

  <div class="panel">
    <h2>MENU</h2>
    <ul id="menu"><li class="empty">메뉴 불러오는 중... (비어 있으면 seed.sh를 실행하세요)</li></ul>
  </div>

  <div class="panel">
    <h2>주문하기</h2>
    <textarea id="say" placeholder="예: 아이스 아메리카노 두 잔인데 한 잔은 샷 추가요, 크루아상도 하나 주세요"></textarea>
    <div class="row">
      <input id="nick" placeholder="닉네임 (주방 보드에 표시)" maxlength="20">
      <button id="go">주문</button>
    </div>
  </div>

  <div class="panel">
    <h2>영수증</h2>
    <div id="receipt"><span class="empty">아직 주문이 없습니다.</span></div>
  </div>
</div>

<script>
"use strict";
var API_BASE = "PASTE_API_URL";

function won(n) { return Number(n || 0).toLocaleString("ko-KR") + "원"; }

fetch(API_BASE + "/menu")
  .then(function (r) { return r.json(); })
  .then(function (d) {
    var ul = document.getElementById("menu");
    if (!d.menu || !d.menu.length) return;
    ul.innerHTML = d.menu.map(function (m) {
      var opts = Object.keys(m.options || {}).map(function (k) {
        return k + (m.options[k] ? " +" + won(m.options[k]) : "");
      }).join(", ");
      return "<li><span>" + m.name + (opts ? ' <span class="opt">(' + opts + ")</span>" : "") +
             '</span><span class="price">' + won(m.price) + "</span></li>";
    }).join("");
  })
  .catch(function () {});

document.getElementById("go").addEventListener("click", function () {
  var text = document.getElementById("say").value.trim();
  if (!text) return;
  var btn = this, box = document.getElementById("receipt");
  btn.disabled = true;
  box.innerHTML = '<span class="empty">AI 접수원이 듣는 중...</span>';
  fetch(API_BASE + "/order", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text: text, customer: document.getElementById("nick").value.trim() || "guest" })
  })
    .then(function (r) { return r.json(); })
    .then(function (d) {
      if (!d.receipt) { box.innerHTML = '<span class="rej">' + (d.error || "접수 실패") + "</span>"; return; }
      var rows = d.receipt.lines.map(function (l) {
        return "<tr><td>" + l.name + (l.options.length ? " (" + l.options.join(", ") + ")" : "") +
               "</td><td class='r'>x" + l.qty + "</td><td class='r'>" + won(l.subtotal) + "</td></tr>";
      }).join("");
      box.innerHTML = '<div class="reply">"' + (d.reply || "") + '"</div>' +
        "<table>" + rows + '<tr class="total"><td>합계</td><td></td><td class="r">' + won(d.receipt.total) + "</td></tr></table>" +
        (d.receipt.rejected.length ? '<div class="rej">제외됨: ' + d.receipt.rejected.join(" / ") + "</div>" : "") +
        (d.order_id ? '<div class="empty">주문번호 ' + d.order_id + " - 주방 보드에서 확인하세요</div>" : "");
    })
    .catch(function (e) { box.innerHTML = '<span class="rej">오류: ' + e.message + "</span>"; })
    .finally(function () { btn.disabled = false; });
});
</script>
</body>
</html>
frontend/kitchen.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude Cafe Kitchen</title>
<style>
  :root { --bg:#12181F; --card:#1B242E; --line:#2A3542; --text:#E8ECF1; --muted:#8B96A5; --accent:#FF9900; --green:#3FB950; --purple:#7a3ff2; }
  * { box-sizing:border-box; margin:0; }
  body { background:var(--bg); color:var(--text); font-family:system-ui,sans-serif; padding:24px; }
  header { display:flex; justify-content:space-between; align-items:baseline; max-width:1100px; margin:0 auto 18px; }
  h1 { font-size:22px; } h1 em { color:var(--accent); font-style:normal; }
  #sales { color:var(--green); font-weight:800; font-size:18px; }
  .cols { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; max-width:1100px; margin:0 auto; }
  .col h2 { font-size:13px; letter-spacing:.08em; margin-bottom:10px; }
  .col.new h2 { color:var(--accent); } .col.making h2 { color:#c9a2ff; } .col.done h2 { color:var(--green); }
  .ticket { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:12px 14px; margin-bottom:10px; font-size:13.5px; }
  .ticket .who { display:flex; justify-content:space-between; color:var(--muted); font-size:12px; margin-bottom:6px; }
  .ticket ul { list-style:none; margin:0 0 8px; }
  .ticket .total { color:var(--accent); font-weight:700; }
  .ticket .note { color:var(--muted); font-size:12px; margin-top:4px; font-style:italic; }
  .ticket button { margin-top:8px; width:100%; background:var(--purple); color:#fff; border:0; border-radius:7px; padding:7px 0; font-weight:700; cursor:pointer; }
  .col.making .ticket button { background:var(--green); color:#12181F; }
  .empty { color:var(--muted); font-size:12.5px; }
</style>
</head>
<body>
<header>
  <h1><em>Claude</em> Cafe, 주방 보드</h1>
  <div>오늘 매출 <span id="sales">0원</span></div>
</header>
<div class="cols">
  <div class="col new"><h2>NEW</h2><div id="new"><div class="empty">주문 대기 중</div></div></div>
  <div class="col making"><h2>MAKING</h2><div id="making"><div class="empty">-</div></div></div>
  <div class="col done"><h2>DONE</h2><div id="done"><div class="empty">-</div></div></div>
</div>

<script>
"use strict";
var API_BASE = "PASTE_API_URL";

function won(n) { return Number(n || 0).toLocaleString("ko-KR") + "원"; }

function ticket(o) {
  var lines = (o.lines || []).map(function (l) {
    return "<li>" + l.name + (l.options.length ? " (" + l.options.join(", ") + ")" : "") + " x" + l.qty + "</li>";
  }).join("");
  var next = o.status === "NEW" ? "MAKING" : o.status === "MAKING" ? "DONE" : null;
  var btn = next ? '<button onclick="advance(\'' + o.sk + "','" + next + '\')">' +
            (next === "MAKING" ? "제조 시작" : "완성") + "</button>" : "";
  return '<div class="ticket"><div class="who"><span>' + (o.customer || "guest") +
         (o.source === "ai" ? " (AI 접수)" : "") + "</span><span>#" + String(o.sk).slice(-4) + "</span></div>" +
         "<ul>" + lines + '</ul><div class="total">' + won(o.total) + "</div>" +
         (o.note ? '<div class="note">"' + o.note + '"</div>' : "") + btn + "</div>";
}

function advance(id, status) {
  fetch(API_BASE + "/status", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ id: id, status: status })
  }).then(refresh).catch(function () {});
}

function refresh() {
  fetch(API_BASE + "/orders")
    .then(function (r) { return r.json(); })
    .then(function (d) {
      document.getElementById("sales").textContent = won(d.sales);
      ["NEW", "MAKING", "DONE"].forEach(function (s) {
        var el = document.getElementById(s.toLowerCase());
        var list = (d.orders || []).filter(function (o) { return o.status === s; });
        el.innerHTML = list.length ? list.map(ticket).join("") : '<div class="empty">-</div>';
      });
    })
    .catch(function () {});
}
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
DEFINITION OF DONE
MISSION 03

AI 접수원, 자연어를 스키마에 가두다

30분

Converse의 toolConfig + toolChoice 강제로 자연어 주문을 place_order 스키마로 추출하고, 그 출력을 영수증 로직이 메뉴판과 대조해 재검증합니다.

모델Global Inference Profile ID선택
Claude Sonnet 4.6global.anthropic.claude-sonnet-4-6기본값, 접수 속도 우수
Claude Sonnet 5global.anthropic.claude-sonnet-5deploy 시 -c orderModelId=... 교체
1Phase 3 실행 지시
Claude 세션 입력
/superpowers:execute-plan Phase 3(자연어 접수)을 구현해줘. 요구사항 2)의 toolConfig 스펙 그대로: place_order 도구 하나, toolChoice로 그 도구 강제, 시스템 프롬프트에 메뉴판 JSON을 넣고 "메뉴에 없는 건 items에 넣지 말고 reply로 안내"를 명시해.
스택은 Bedrock IAM 정책 없이, 주문 Lambda environment에
AWS_BEARER_TOKEN_BEDROCK: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "" 를 주입해.
2배포와 자연어 주문
Terminal, 전체 복사
set -a; source ~/capstone/.env; set +a   # Bearer 키를 배포 셸에 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
curl -s -X POST "$API/order" -H 'Content-Type: application/json' \
  -d '{"text":"아이스 아메리카노 두 잔인데 한 잔은 샷 추가요, 크루아상도 하나 주세요","customer":"first-guest"}' \
  | jq '{reply, lines: .receipt.lines, total: .receipt.total}'
이후의 모든 deploy는 .env 로드가 선행 스택이 배포 셸의 AWS_BEARER_TOKEN_BEDROCK을 읽어 Lambda에 넣으므로, 새 터미널에서 deploy할 때는 항상 set -a; source ~/capstone/.env; set +a를 먼저 실행하세요. 빈 값으로 배포되면 접수원이 401을 반환합니다.
출력 예시
{
  "reply": "네, 아이스 아메리카노 두 잔(한 잔 샷 추가)과 크루아상 하나 접수했습니다!",
  "lines": [
    { "name": "아이스 아메리카노", "qty": 1, "options": [], "subtotal": 4500 },
    { "name": "아이스 아메리카노", "qty": 1, "options": ["shot"], "subtotal": 5000 },
    { "name": "크루아상", "qty": 1, "options": [], "subtotal": 3800 }
  ],
  "total": 13300
}
3변화구 3종 세트
변화구주문 문장기대 동작
한국어 수량"라떼 석 잔이요, 전부 디카페인으로"qty 3, options에 decaf
없는 메뉴"티라미수 하나랑 아아 하나"티라미수는 items 제외, reply에서 안내, 아아만 접수
없는 옵션"크루아상 데워서 두 개"receipt.rejected에 unknown option 기록, 주문은 접수

고객 페이지에서도 직접 말로 주문해 보세요. 영수증의 reply 멘트가 모델마다 달라지는 것, 그리고 rejected가 뜨는 순간이 이 아키텍처의 요점입니다. AI가 무엇을 뽑아 오든 서버의 receipt가 메뉴판이라는 단일 진실과 대조합니다.

왜 toolChoice 강제인가 (해부) 자유 텍스트에 "JSON으로만 답해"라고 부탁하는 대신, 도구 호출을 강제하면 응답이 스키마 검증된 toolUse.input으로만 옵니다. 아래는 order.mjs의 해당 부분입니다.
order.mjs 중 extractOrder (해부)
async function extractOrder(menu, text) {
  const res = await brt.send(new ConverseCommand({
    modelId: MODEL_ID,
    system: [{
      text: `당신은 카페 주문 접수원입니다. 메뉴판: ${JSON.stringify(menu)}. ` +
        "손님의 말을 place_order 도구 호출로만 응답하세요. " +
        "메뉴판에 없는 것은 items에 넣지 말고 reply에서 정중히 안내하세요. " +
        "reply는 한국어 한두 문장의 접수 멘트입니다.",
    }],
    messages: [{ role: "user", content: [{ text: String(text).slice(0, 1000) }] }],
    toolConfig: {
      tools: [{
        toolSpec: {
          name: "place_order",
          description: "구조화된 카페 주문 접수",
          inputSchema: {
            json: {
              type: "object",
              properties: {
                items: {
                  type: "array",
                  items: {
                    type: "object",
                    properties: {
                      menu_id: { type: "string" },
                      qty: { type: "integer" },
                      options: { type: "array", items: { type: "string" } },
                    },
                    required: ["menu_id", "qty"],
                  },
                },
                reply: { type: "string" },
              },
              required: ["items", "reply"],
            },
          },
        },
      }],
      toolChoice: { tool: { name: "place_order" } },
    },
    inferenceConfig: { maxTokens: 700 },
  }));
  const tu = res.output?.message?.content?.find((c) => c.toolUse)?.toolUse;
  if (!tu) throw new Error("no toolUse in response");
  return tu.input;
}
이 미션의 3대 함정 (A와 동일 계보) ① ValidationException(on-demand 미지원) = 베이스 모델 ID, global. 프로파일로. ② AccessDeniedException = 발급 계정의 모델 액세스 미승인. ②-1 401/UnrecognizedClientException = 키 오탈자, 만료, 또는 .env 미로드 배포(로드 후 재배포). ③ 29초 부근 타임아웃 = HTTP API 상한, maxTokens 700 유지. "no toolUse in response"가 뜨면 toolChoice 블록이 빠졌거나 모델 ID가 도구 미지원 구모델인 경우입니다.
DEFINITION OF DONE
MISSION 04

주문 러시, 손님을 합성하다

20분

Chapter 5의 헤드리스로 손님 페르소나 주문문을 생성해 API에 밀어넣는 러시 시뮬레이터를 돌리고, 개점 절차 전체를 /open-cafe 스킬 하나로 접습니다. 주방 보드가 붐비는 걸 지켜보세요.

1러시 시뮬레이터
Terminal, 전체 복사 (worktree에서)
cat > rush.sh << 'RUSHEOF'
#!/bin/bash
# 사용: ./rush.sh [건수]  - Claude 헤드리스가 손님 주문문을 만들어 카페 API에 밀어넣는다
set -euo pipefail
API="${CAFE_API:?CAFE_API 필요 (카페 ApiUrl)}"
N="${1:-8}"

echo "[rush] 손님 ${N}명 생성 중..."
LINES=$(claude -p "한국 카페 손님이 말할 법한 자연어 주문 문장을 정확히 ${N}개 만들어 JSON 문자열 배열로만 출력해. 메뉴는 아이스 아메리카노, 라떼, 크루아상뿐이고 수량과 옵션(샷 추가, 디카페인)을 다양하게 섞어. 코드펜스와 설명 금지." \
  --output-format json | jq -r '.result')

echo "$LINES" | jq -r '.[]' | while IFS= read -r line; do
  printf '주문: %s\n' "$line"
  curl -s -X POST "$API/order" -H 'Content-Type: application/json' \
    -d "$(jq -n --arg t "$line" '{text:$t, customer:"rush-bot"}')" | jq -r '.reply // .error'
  sleep 1
done
echo "[rush] 완료. 주방 보드가 붐빕니다."
RUSHEOF
chmod +x rush.sh
./rush.sh 8

주방 보드를 옆에 두고 실행하세요. rush-bot 손님 8명이 1초 간격으로 들이닥칩니다. jq -n --arg로 사용자 입력을 안전하게 JSON에 넣는 것, 헤드리스 결과를 jq -r '.result'로 뽑아 배열 반복하는 것, 전부 Chapter 5 Task 3의 재활용입니다.

2/open-cafe 스킬
Terminal, 전체 복사
mkdir -p .claude/skills/open-cafe
cat > .claude/skills/open-cafe/SKILL.md << 'CAFEEOF'
---
description: 카페 개점 원커맨드. 배포, 메뉴 시딩, 스모크 주문, 개점 안내까지 수행한다.
disable-model-invocation: true
argument-hint: "[카페이름]"
allowed-tools: Bash(npx cdk *) Bash(./seed.sh) Bash(curl *) Bash(jq *) Read
---

카페를 개점하세요. 카페 이름: $ARGUMENTS

1. `npx cdk deploy --require-approval never --outputs-file cdk-outputs.json` 실행.
2. cdk-outputs.json에서 ApiUrl, SiteUrl, KitchenUrl을 읽으세요.
3. `curl -s <ApiUrl>/menu` 의 menu가 비어 있으면 `./seed.sh` 실행.
4. 스모크 주문: `curl -s -X POST <ApiUrl>/order -H "Content-Type: application/json" -d '{"items":[{"menu_id":"croissant","qty":1}],"customer":"smoke"}'` 가 201이어야 합니다.
5. 개점 안내를 표로 출력: 고객 주문(SiteUrl), 주방 보드(KitchenUrl), 오늘의 메뉴 3종과 가격.
6. 마지막 줄에 "$ARGUMENTS, 개점했습니다"를 선언하세요.
CAFEEOF
echo "새 세션에서 /open-cafe 사용 가능"
Claude 세션 입력 (새 세션에서)
/open-cafe 우형의 다락방
DEFINITION OF DONE
MISSION 05

개점 스트레스 테스트

15분

이제 여러분이 가장 까다로운 손님입니다. 변화구 카드를 고객 페이지에 던져 접수원의 한계와 방어선을 확인하세요. 거절과 안내가 정확히 나오는 순간이 진짜 개점입니다.

1변화구 6장, 접수원 압박 면접

고객 페이지에서 아래 카드 중 최소 3장(1, 5번 함정 포함 권장)을 주문하고, 영수증의 reply와 rejected, 그리고 주방 보드의 티켓을 함께 관찰하세요. "제조 시작"과 "완성" 버튼으로 마감까지 밀어 주는 것이 접객의 끝입니다.

#변화구 주문관전 포인트
1"아아 세 잔인데 한 잔은 뜨겁게요"메뉴에 핫이 없다, 접수원의 안내 멘트
2"제일 싼 걸로 두 개 주세요"가격 추론, 크루아상 2개가 나오는가
3"라떼 넉 잔, 다 디카페인, 두 잔은 샷도 추가"수량 한국어 + 옵션 분배
4"티라미수 있어요? 없으면 크루아상이요"조건부 주문 처리
5"아까 시킨 거 취소하고 라떼 하나만요"취소 기능이 없다, 정직한 안내가 나오는가
6"단체 주문이요, 아아 열다섯 잔"qty 클램프(10)와 rejected 없는 감량, 영수증 확인
2마감 정산
Terminal, 전체 복사
curl -s "$API/orders" | jq '{오늘_매출: .sales, 주문_수: (.orders | length), AI_접수: [.orders[] | select(.source == "ai")] | length}'

git add -A && git commit -m "feat: ai cafe capstone grand open"

# 실습을 마쳤다면 비용 정리 (이어서 볼 계획이면 유지):
# npx cdk destroy --force
DEFINITION OF DONE
OPTION

하네스 엔지니어링, 작품을 성숙시키고 등급을 받다

+15분 옵션

지금까지 만든 것은 결과물입니다. 이 옵션은 그 결과물을 만든 작업 환경, 즉 하네스 (CLAUDE.md, hooks, skills, commands, agents)를 표준 구조로 성숙시키고(project-init), 그 품질을 6개 차원(정확성, 안전성, 완전성, 실행 가능성, 일관성, 검증 가능성)에서 채점받는(harness-eval) 단계입니다. superpowers의 3단(brainstorm, write-plan, execute-plan) 뒤에 4단 성숙화와 5단 평가를 붙이는 셈입니다.

1플러그인 2종 설치
Terminal, 전체 복사
claude plugin marketplace add https://github.com/whchoi98/project-init
claude plugin install project-init@project-init
claude plugin marketplace add https://github.com/whchoi98/harness-eval
claude plugin install harness-eval@harness-eval
claude plugin list

설치 후 새 Claude 세션을 시작하세요, superpowers 때와 같은 규칙입니다.

2성숙화, /project-init:init-project와 /project-init:sync-docs
Claude 세션 입력 (새 세션, worktree에서)
/project-init:init-project .

기존 프로젝트 감지 모드로 동작합니다. 이 캡스톤에서 손으로 만든 /open-cafe 스킬 같은 기존 하네스는 보존하고, 없는 것(문서 스캐폴딩, 시크릿 스캔 훅, 테스트 프레임워크, /review와 /test-all 커맨드 등)만 채웁니다. CLAUDE.md가 이미 있으면 덮어쓰기 전에 물어봅니다. 끝나면 이어서:

Claude 세션 입력
/project-init:sync-docs
출력 예시 (요약)
## Sync Report
### Quality Scores (Before -> After)
| ./CLAUDE.md          | C (62) | B (84) | +22 |
| ./lambda/CLAUDE.md   | F (12) | C (58) | +46 |
### Changes Made
- Files created: 4 / Files updated: 2 / Runbooks missing: 1
3평가, /harness-eval로 등급 받기
Claude 세션 입력
/harness-eval:quick
출력 예시
{"mode": "quick", "scores": {"overall": 7.2, "grade": "B"}, "checklist": {"pass": 13, "warn": 2, "fail": 1}}

"AI의 출력은 서버가 재검증한다"는 원칙을 CLAUDE.md의 비자명한 패턴으로 기록해 두면 sync-docs 점수가 오르고, 다음에 이 저장소를 여는 Claude가 같은 원칙을 지킵니다. 여유가 있다면 이 캡스톤에서 실제로 내린 결정을 기록으로 남기세요:

Claude 세션 입력
/add-adr toolchoice-forced-extraction
/add-runbook open-cafe
시간과 신뢰 가드 잔여 시간이 빠듯하면 설치와 /harness-eval:quick(30초 체크리스트)만으로 충분합니다, /project-init:init-project는 17단계라 수 분이 걸립니다. /harness-eval:standard의 동적 분석은 대상 프로젝트의 훅과 테스트를 실제로 실행합니다. 방금 본인이 만든 프로젝트라 안전하지만, 남의 저장소를 평가할 때는 --static-only 플래그가 예의이자 안전입니다.
OPTION DONE, 진행률 미집계
APPENDIX

컷라인과 트러블슈팅

비상용

90분 시점 기준, 순서대로 적용합니다. 어떤 컷에서도 M3의 자연어 접수와 M5의 변화구 시험은 지킵니다.

조치절약
CUT 1M4 러시 생략, /open-cafe 스킬만 설치~8분
CUT 2M2 kitchen.html을 완성본 배치로 대체, 서브에이전트는 index.html만~7분
CUT 3M3 변화구를 2번(없는 메뉴) 하나로 축소~5분
T트러블슈팅 표
증상원인처방
"no toolUse in response"toolChoice 누락 또는 도구 미지원 구모델toolConfig에 toolChoice 블록 확인, global. 프로파일 확인
409 "메뉴가 비었습니다"seed 미실행 또는 다른 테이블./seed.sh, OPS_OUTPUTS가 현재 worktree인지 확인
영수증에 rejected만 가득모델이 메뉴 id 대신 이름을 추측(환각)시스템 프롬프트에 메뉴판 JSON 포함 여부 확인, receipt가 방어 중인 정상 신호
ValidationException / AccessDenied베이스 ID 또는 IAM 2종 누락M3 표의 프로파일 ID, foundation-model + inference-profile 확인
/order만 30초 타임아웃HTTP API 상한maxTokens 700 유지, 시스템 프롬프트 축소
고객 페이지 메뉴 빈 화면CloudFront 전파 또는 API_BASE 미교체수 분 대기, 두 html의 API_BASE 확인 후 재배포
주방 보드에 주문이 안 뜸다른 스택의 API를 보는 중kitchen.html의 API_BASE와 $API 일치 확인

마무리, 챕터 소환 지도

캡스톤 C 장면소환된 챕터핵심 역량
brainstorm의 스키마 명세Ch1 + superpowers요구사항이 곧 설계 품질
2뷰 병렬 빌드Ch2서브에이전트 2개 동시 위임
Bedrock IAM 2종, 모델 컨텍스트 교체Ch3최소 권한, Global CRIS
/open-cafe 원커맨드Ch4운영 절차의 스킬화
rush.sh 손님 합성Ch5헤드리스 생성 + jq -n 안전 주입
NEXT

Chapter 6 - Agent SDK

변화구 5번("아까 시킨 거 취소")에서 접수원의 한계가 드러났습니다. 주문 맥락을 기억하는 멀티턴, 재고를 조회하는 커스텀 도구, 취소와 변경이 되는 진짜 점원은 Agent SDK의 영역입니다. 오늘의 카페가 그대로 다음 장의 무대입니다.