diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 56931ab..a3408af 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -5,6 +5,7 @@ "skills": [ "./skills/spec-driven/writing-specs", "./skills/spec-driven/writing-tasks", + "./skills/spec-driven/writing-flows", "./skills/spec-driven/implement-with-test", "./skills/spec-driven/test-commit-push-pr-clean", "./skills/agents/create-team", diff --git a/AGENTS.md b/AGENTS.md index d92834d..536ca2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ archived/ # retired skills/plugins (not shipped) ``` ### Categories -- **spec-driven** — writing-specs, writing-tasks, implement-with-test, test-commit-push-pr-clean +- **spec-driven** — writing-specs, writing-tasks, writing-flows, implement-with-test, test-commit-push-pr-clean - **agents** — create-team, split-work - **browser** — browser-walkthrough, computer-use-test, ui-prototype-preview - **productivity** — brain-storm, session-resume, llm-wiki diff --git a/CONTEXT.md b/CONTEXT.md index 1287ad5..909f406 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # claude-plugins — Spec/Task Authoring Language -The vocabulary used by the authoring plugins (`writing-specs`, `writing-tasks`) for how generated documents are organized inside a target project. This is a glossary, not a spec. +The vocabulary used by the authoring plugins (`writing-specs`, `writing-tasks`, `writing-flows`) for how generated documents are organized inside a target project. This is a glossary, not a spec. ## Language @@ -12,6 +12,10 @@ _Avoid_: requirement doc, ticket An executable, dependency-aware decomposition of a Spec, tracked as a file with frontmatter. One Task references exactly one Spec. _Avoid_: issue, todo +**Flow**: +A single document capturing how one scenario behaves at runtime — actors/trigger (use case), a sequence diagram, and the step-by-step user flow with branches — in one fixed template. One Flow = one scenario. +_Avoid_: use case doc, diagram file, user journey + **Topic**: The coherent feature/domain a Spec or Task belongs to (e.g. `auth`, `news`, `race`). The top-level organizing axis under `docs/specs/` and `docs/tasks/`, inferred from the conversation that produced the document. _Avoid_: phase, feature-folder, module @@ -25,7 +29,8 @@ _Avoid_: dependency (that is the frontmatter graph; this is the WHY it can't hol ## Relationships -- A **Topic** groups one or more **Specs** +- A **Topic** groups one or more **Specs**, and zero or more **Flows** +- A **Flow** documents exactly one scenario; it may mention Specs in prose but carries no structural reference (no frontmatter link) - A **Spec** is decomposed into one or more **Tasks** - A **Task** references exactly one **Spec**, and zero or more other **Tasks** via `depends_on` diff --git a/evals/writing-flows/evals.json b/evals/writing-flows/evals.json new file mode 100644 index 0000000..a0b58de --- /dev/null +++ b/evals/writing-flows/evals.json @@ -0,0 +1,53 @@ +{ + "skill_name": "writing-flows", + "evals": [ + { + "id": 0, + "eval_name": "checkout-sequence-from-code", + "prompt": "결제(체크아웃)가 어떤 순서로 동작하는지 한눈에 보고 싶어. 플로우 문서로 정리해줘.", + "expected_output": "flows//01-*.md 가 생성되고, 4섹션(Context/Diagram/Steps & Branches/Source)을 갖추며, Diagram은 mermaid sequenceDiagram 하나로 Buyer→checkout→inventory→PG→order→notification 체인을 그린다. 재고 부족(409), 결제 거절(402, 예약 해제) 분기가 Steps에 나타나고 Source에 실제 탐색한 파일 경로가 나열된다.", + "files": ["shop-app"], + "expectations": [ + "A flow file is created under flows// with a 2-digit NN prefix and lowercase-hyphen name (not flows/phase-N/)", + "The file contains exactly the four template sections: Context, Diagram, Steps & Branches, Source", + "The Diagram section contains exactly one mermaid block of type sequenceDiagram or flowchart (not both, not other types)", + "The diagram reflects the real code chain including the payment gateway (PG) call", + "Steps & Branches includes the out-of-stock branch and the payment-declined branch (with stock reservation release)", + "The Source section lists real file paths from the project (e.g. src/routes/checkout.ts) without line numbers", + "A final report is printed with Action, File, and the basis (code files explored)", + "No flows/README.md or flows/INDEX.md is created" + ] + }, + { + "id": 1, + "eval_name": "signup-userflow-branching", + "prompt": "회원가입 유저 플로우 그려줘. 이메일 인증까지 포함해서 어떤 케이스에서 어떻게 분기되는지 보고 싶어.", + "expected_output": "flows// 아래 회원가입 Flow가 생성된다. 분기(잘못된 입력 400, 기존 미인증 유저 재발송, 이메일 중복 409, 토큰 만료 → /signup/expired)가 Diagram 또는 Steps에 반영된다. 사용자 분기 중심이므로 flowchart 선택이 자연스럽지만 sequenceDiagram도 허용.", + "files": ["shop-app"], + "expectations": [ + "A flow file is created under flows// following the NN-name convention", + "The file contains exactly the four template sections: Context, Diagram, Steps & Branches, Source", + "The Diagram section contains exactly one mermaid block (sequenceDiagram or flowchart)", + "The verification-token-expired branch (redirect to re-request) appears in the document", + "The duplicate-email and unverified-resend branches both appear in the document", + "The Source section lists src/routes/signup.ts (file path only, no line numbers)", + "The signup scenario is one file — it is not merged with checkout or other scenarios" + ] + }, + { + "id": 2, + "eval_name": "design-stage-flow-from-spec", + "prompt": "추천인 리워드 기능 플로우 작성해줘. 아직 구현 전이고 specs/referral/01-referral-reward.md 스펙 기반으로.", + "expected_output": "코드가 없으므로 스펙을 입력으로 설계 Flow를 생성한다. Source 섹션에 스펙 경로 또는 design 표기가 들어가고, 7일 지연 지급/환불 취소/셀프 추천 거절 분기가 Steps에 반영된다. 리포트에 코드가 아닌 스펙 기반임이 명시된다.", + "files": ["design-stage"], + "expectations": [ + "A flow file is created under flows// following the NN-name convention", + "The file contains exactly the four template sections: Context, Diagram, Steps & Branches, Source", + "The Source section references the spec path or marks the flow as design-stage (no fabricated code paths)", + "The 7-day delayed reward and refund-cancellation branch appear in the document", + "The self-referral rejection branch appears in the document", + "The final report states the flow was derived from the spec, not code" + ] + } + ] +} diff --git a/evals/writing-flows/files/design-stage/specs/referral/01-referral-reward.md b/evals/writing-flows/files/design-stage/specs/referral/01-referral-reward.md new file mode 100644 index 0000000..9cef003 --- /dev/null +++ b/evals/writing-flows/files/design-stage/specs/referral/01-referral-reward.md @@ -0,0 +1,22 @@ +# Referral Reward + +## Purpose + +Reward existing users with credit when a friend they invited completes a first purchase, to drive organic growth. + +## Requirements + +- Each user has a unique referral code shareable as a link +- When an invited friend signs up via the link and completes their first purchase, both sides receive 5,000 KRW credit +- Credit is granted at most once per invited friend; self-referral is rejected +- Reward grant is delayed 7 days after the purchase to absorb refunds — a refund within the window cancels the reward + +## Approach + +Referral attribution is stored at signup time by resolving the code in the invite link. Reward granting runs as a scheduled job that scans purchases older than 7 days with pending referral rewards, skipping refunded orders. Credits are issued through the existing wallet service. + +## Verification + +- Invited signup followed by first purchase creates a pending reward for both users +- Refund within 7 days cancels the pending reward +- Self-referral and second purchases grant nothing diff --git a/evals/writing-flows/files/shop-app/src/routes/checkout.ts b/evals/writing-flows/files/shop-app/src/routes/checkout.ts new file mode 100644 index 0000000..1e37bf2 --- /dev/null +++ b/evals/writing-flows/files/shop-app/src/routes/checkout.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { reserveStock } from "../services/inventory"; +import { requestPayment } from "../services/payment"; +import { confirmOrder } from "../services/order"; +import { sendOrderNotification } from "../services/notification"; + +const router = Router(); + +// POST /checkout — buyer clicks "결제하기" in the cart screen +router.post("/checkout", async (req, res) => { + const { cartId, paymentMethod } = req.body; + + const reservation = await reserveStock(cartId); + if (!reservation.ok) { + return res.status(409).json({ error: "OUT_OF_STOCK", items: reservation.missing }); + } + + const payment = await requestPayment(cartId, paymentMethod); + if (payment.status === "DECLINED") { + await reservation.release(); + return res.status(402).json({ error: "PAYMENT_DECLINED", retryUrl: "/checkout/retry" }); + } + + const order = await confirmOrder(cartId, payment.transactionId); + await sendOrderNotification(order.id); + + return res.status(201).json({ orderId: order.id }); +}); + +export default router; diff --git a/evals/writing-flows/files/shop-app/src/routes/signup.ts b/evals/writing-flows/files/shop-app/src/routes/signup.ts new file mode 100644 index 0000000..9acee64 --- /dev/null +++ b/evals/writing-flows/files/shop-app/src/routes/signup.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { db } from "../lib/db"; +import { sendVerificationEmail } from "../services/email"; + +const router = Router(); + +// POST /signup — from the signup screen +router.post("/signup", async (req, res) => { + const { email, password } = req.body; + + if (!isValidEmail(email) || password.length < 8) { + return res.status(400).json({ error: "INVALID_INPUT" }); + } + + const existing = await db.findUserByEmail(email); + if (existing) { + // Existing but unverified → resend verification instead of erroring + if (!existing.verified) { + await sendVerificationEmail(existing.id, email); + return res.status(200).json({ status: "VERIFICATION_RESENT" }); + } + return res.status(409).json({ error: "EMAIL_TAKEN", loginUrl: "/login" }); + } + + const user = await db.createUser(email, password, { verified: false }); + await sendVerificationEmail(user.id, email); + return res.status(201).json({ status: "VERIFICATION_SENT" }); +}); + +// GET /signup/verify?token= — link in the verification email +router.get("/signup/verify", async (req, res) => { + const claim = await db.consumeVerificationToken(String(req.query.token)); + if (!claim) { + return res.redirect("/signup/expired"); // expired or already used → re-request screen + } + await db.markVerified(claim.userId); + return res.redirect("/welcome"); +}); + +function isValidEmail(email: string) { + return /^[^@]+@[^@]+\.[^@]+$/.test(email); +} + +export default router; diff --git a/evals/writing-flows/files/shop-app/src/services/inventory.ts b/evals/writing-flows/files/shop-app/src/services/inventory.ts new file mode 100644 index 0000000..2b90d8a --- /dev/null +++ b/evals/writing-flows/files/shop-app/src/services/inventory.ts @@ -0,0 +1,14 @@ +import { db } from "../lib/db"; + +export async function reserveStock(cartId: string) { + const items = await db.cartItems(cartId); + const missing = items.filter((i) => i.stock < i.quantity); + if (missing.length > 0) { + return { ok: false as const, missing: missing.map((i) => i.sku) }; + } + const reservationId = await db.reserve(items); + return { + ok: true as const, + release: () => db.releaseReservation(reservationId), + }; +} diff --git a/evals/writing-flows/files/shop-app/src/services/notification.ts b/evals/writing-flows/files/shop-app/src/services/notification.ts new file mode 100644 index 0000000..9111735 --- /dev/null +++ b/evals/writing-flows/files/shop-app/src/services/notification.ts @@ -0,0 +1,6 @@ +import { pushQueue } from "../lib/queue"; + +export async function sendOrderNotification(orderId: string) { + // Fire-and-forget: enqueue only, the worker delivers the push/email. + await pushQueue.enqueue({ type: "ORDER_CONFIRMED", orderId }); +} diff --git a/evals/writing-flows/files/shop-app/src/services/order.ts b/evals/writing-flows/files/shop-app/src/services/order.ts new file mode 100644 index 0000000..1def2b8 --- /dev/null +++ b/evals/writing-flows/files/shop-app/src/services/order.ts @@ -0,0 +1,7 @@ +import { db } from "../lib/db"; + +export async function confirmOrder(cartId: string, transactionId: string) { + const order = await db.createOrder(cartId, transactionId); + await db.clearCart(cartId); + return order; +} diff --git a/evals/writing-flows/files/shop-app/src/services/payment.ts b/evals/writing-flows/files/shop-app/src/services/payment.ts new file mode 100644 index 0000000..c9b4c8b --- /dev/null +++ b/evals/writing-flows/files/shop-app/src/services/payment.ts @@ -0,0 +1,11 @@ +import { pgClient } from "../lib/pg-client"; + +export async function requestPayment(cartId: string, method: string) { + // Calls the external PG (payment gateway). Times out after 10s → treated as DECLINED. + try { + const result = await pgClient.charge({ cartId, method, timeoutMs: 10_000 }); + return { status: result.approved ? "APPROVED" : "DECLINED", transactionId: result.txId }; + } catch (e) { + return { status: "DECLINED", transactionId: null }; + } +} diff --git a/skills/spec-driven/writing-flows/SKILL.md b/skills/spec-driven/writing-flows/SKILL.md new file mode 100644 index 0000000..1a40073 --- /dev/null +++ b/skills/spec-driven/writing-flows/SKILL.md @@ -0,0 +1,60 @@ +--- +name: writing-flows +description: "Writes single-scenario Flow documents under flows// — use case context, one Mermaid diagram (sequence or flowchart), step-by-step branches, and source file references — so how a service behaves end-to-end can be grasped at a glance. Use when user asks for a sequence diagram, user flow, use case doc, or mentions 시퀀스 다이어그램, 유저 플로우, 플로우 그려줘, 동작 흐름, 플로우 작성. Also trigger on intent like 어떻게 동작하는지 한눈에 보고 싶다, 동작 순서 정리해줘 — but not on simple code questions about a single function." +allowed-tools: Read, Write, Glob, Grep, Bash, AskUserQuestion +context: fork +agent: general-purpose +--- + +# Writing Flows + +Document how one scenario behaves at runtime as a **Flow** file under `flows/`. A Flow makes the order of operations and the branching visible at a glance — the thing specs, tasks, and ADRs can't show. + +## Hard Rules + +- **Template-driven.** Every Flow follows [templates/flow-template.md](templates/flow-template.md) exactly — four sections, no freestyle. +- **One Flow = one scenario.** A request spanning multiple scenarios becomes multiple Flow files. +- **Code is the source of truth.** When code exists, read it before drawing. Never draw from assumption what can be verified by exploration. +- **Never auto-modify existing Flows.** Propose the diff, wait for confirmation. +- **No derived index files.** Never create `flows/README.md` or `flows/INDEX.md` — the directory listing is the index. + +## Directory Rules + +- Flows live in `flows//NN-name.md` at the project root. Topic = coherent feature/domain (`auth`, `checkout`), the same axis as ADR-0001 — never phase numbers. +- Match the topic against existing `flows/*/` directories before creating a new one. +- `NN` is a 2-digit index within the topic, starting `01`. Filenames are lowercase-with-hyphens. + +## Phase 1 — Search + +1. `Glob flows/**/*.md` and check whether a Flow for this scenario already exists. + - Exact scenario match → ask via `AskUserQuestion`: update, create new, or cancel. + - Existing Flow whose `## Source` files no longer exist (verify with `Glob`) → report as outdated; propose an update, never apply silently. +2. Locate the scenario in code: entry point (route/handler/command), the calls it makes, and where behavior branches. Follow the chain far enough to know every actor and every branch — guessing here produces a wrong diagram. +3. No relevant code (design-stage scenario)? Fall back to specs (`Glob specs/**/*.md`) or conversation context, and say so in the report. + +## Phase 2 — Write + +Read [templates/flow-template.md](templates/flow-template.md) and fill it strictly: + +- **Context** — 2-3 sentences: the scenario, its trigger, the actors involved. +- **Diagram** — exactly one Mermaid diagram. `sequenceDiagram` when the story is interaction between components/services; `flowchart TD` when it is user-facing branching between screens/states. Never both, never other types. +- **Steps & Branches** — numbered happy path; attach branches and edge cases to the step where they diverge (`— 실패 시 → ...`). +- **Source** — the files the Flow was derived from, file paths only (no line numbers — they go stale). For design-stage Flows, list the spec or write `(design — no code yet)`. + +Diagram and Steps must agree with each other and with the code. On update, preserve sections the user didn't ask to change. + +## Phase 3 — Report + +End with a short report: Action (Created/Updated), File (relative path), Scenario (H1), Basis (code files explored, or spec/conversation), and any outdated Flows found in Phase 1. One actionable next step (e.g. a neighboring scenario worth documenting). + +## Anti-patterns + +**WRONG:** drawing the diagram from the user's description while the code says otherwise. +**RIGHT:** explore the code first; if it contradicts the user's description, surface the difference and ask which one the Flow should capture. + +**WRONG:** one giant Flow covering signup, login, and password reset. +**RIGHT:** three Flow files in `flows/auth/`, each one scenario. + +## Boundaries + +This skill only writes Flow files. Requirements belong to `writing-specs`, work breakdown to `writing-tasks`. It never modifies source code. diff --git a/skills/spec-driven/writing-flows/templates/flow-template.md b/skills/spec-driven/writing-flows/templates/flow-template.md new file mode 100644 index 0000000..1d80292 --- /dev/null +++ b/skills/spec-driven/writing-flows/templates/flow-template.md @@ -0,0 +1,22 @@ +# {Scenario Title} Flow + +## Context + +{2-3 sentences: what scenario this is, what triggers it, which actors are involved.} + +## Diagram + +```mermaid +{exactly one diagram: sequenceDiagram (component interaction) or flowchart TD (user-facing branching)} +``` + +## Steps & Branches + +1. {step} — {branch/edge case if it diverges here → outcome} +2. {step} +3. {step} — {branch → outcome} + +## Source + +- {path/to/file.ts} +- {path/to/other-file.ts}