T001–T006: Phase 1 setup (workspace, Biome, TS, Vitest, layer boundary enforcement)

This commit is contained in:
Lukas
2026-03-03 12:54:29 +01:00
parent ddb2b317d3
commit 7dd4abb12a
27 changed files with 2655 additions and 35 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
node_modules/
dist/
build/
*.log
.env*
.DS_Store
Thumbs.db
*.tmp
*.swp
.vscode/
.idea/
coverage/
*.tsbuildinfo

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -1,50 +1,145 @@
# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
<!--
Sync Impact Report
───────────────────
Version change: 1.0.2 → 1.0.3 (PATCH — add merge-gate rule)
Modified sections:
- Development Workflow: added automated-checks merge gate
Templates requiring updates:
- .specify/templates/plan-template.md ✅ no update needed
- .specify/templates/spec-template.md ✅ no update needed
- .specify/templates/tasks-template.md ✅ no update needed
Follow-up TODOs: none
-->
# Encounter Console Constitution
## Core Principles
### [PRINCIPLE_1_NAME]
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
### I. Deterministic Domain Core
### [PRINCIPLE_2_NAME]
<!-- Example: II. CLI Interface -->
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
All domain logic MUST be implemented as pure state transitions.
Given the same input state and action, the output state MUST be
identical across runs. Side effects (I/O, randomness, clocks) MUST
be injected at the boundary, never sourced inside the domain layer.
### [PRINCIPLE_3_NAME]
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
- State transitions MUST be expressed as pure functions.
- Random values (e.g., dice rolls) MUST be passed in as resolved
inputs, not generated within the domain.
- Domain functions MUST NOT depend on wall-clock time, network, or
filesystem.
### [PRINCIPLE_4_NAME]
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### II. Layered Architecture
### [PRINCIPLE_5_NAME]
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
The codebase MUST be organized into four layers with strict
dependency direction:
## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
1. **Domain** — pure types, state transitions, validation rules.
No imports from other layers.
2. **Application** — orchestrates domain operations, manages
workflows, coordinates use cases. Application defines port
interfaces that Adapters implement. May import Domain only.
3. **Adapters** — I/O, persistence, UI rendering, external APIs.
May import Application and Domain.
4. **Agent** — AI-assisted features (suggestions, analysis).
May import Application and Domain as read-only consumers.
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
A module in an inner layer MUST NOT import from an outer layer.
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
### III. Agent Boundary
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
The agent layer MAY read domain events and current state. The agent
MAY produce suggestions, annotations, or recommendations. The agent
MUST NOT mutate domain state directly. All agent-originated changes
MUST flow through the Application layer as explicit user-confirmed
commands.
- Agent output MUST be clearly labeled as suggestions.
- No silent or automatic application of agent recommendations.
### IV. Clarification-First
Before making any non-trivial assumption during specification,
planning, or implementation, the agent MUST surface a clarification
question to the user. "Non-trivial" means any decision that would
alter observable behavior, data model shape, or public API surface.
The agent MUST also ask when multiple valid interpretations exist,
when a choice would affect architectural layering, or when scope
would expand beyond the current spec. The agent MUST NOT silently
choose among valid alternatives.
### V. Escalation Gates
Any feature, requirement, or scope change not present in the current
spec MUST be rejected at implementation time until the spec is
explicitly updated. The workflow is:
1. Identify the out-of-scope item.
2. Document it as a proposal.
3. Update the spec via `/speckit.specify` or `/speckit.clarify`.
4. Only then proceed with implementation.
### VI. MVP Baseline Language
Constraints in this constitution and in specs MUST use MVP baseline
language ("MVP baseline does not include X") rather than permanent
architectural bans ("X is forbidden"). This preserves the option to
add capabilities in future iterations without constitutional
amendment. The current MVP baseline is local-first and single-user;
this is a starting scope, not a permanent restriction.
### VII. No Gameplay Rules in Constitution
This constitution MUST NOT contain concrete gameplay mechanics,
rule-system specifics, or encounter resolution logic. Such details
belong in feature specs. The constitution governs process,
architecture, and quality — not product behavior.
## Scope Constraints
- The Encounter Console's primary focus is initiative tracking and
encounter state management. Adjacent capabilities (e.g., richer
game-engine features) are not in the MVP baseline but may be
added via spec updates in future iterations.
- Technology choices, UI framework, and storage mechanism are
spec-level decisions, not constitutional mandates.
- Testing strategy (unit, integration, contract) is determined per
feature spec; the constitution requires only that domain logic
remains testable via pure-function assertions.
## Development Workflow
- No change may be merged unless all automated checks (tests and
static analysis as defined by the project) pass.
- Every feature begins with a spec (`/speckit.specify`).
- Implementation follows the plan → tasks → implement pipeline.
- Domain logic MUST be testable without mocks for external systems.
- Long-running or multi-step state transitions SHOULD be verifiable
through reproducible event logs or snapshot-style tests.
- Commits SHOULD be atomic and map to individual tasks where
practical.
- Layer boundary compliance MUST be verified by automated import
rules or architectural tests. Agent-assisted or manual review MAY
supplement but not replace automated checks.
## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES]
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
This constitution is the highest-authority document for the
Encounter Console project. All specs, plans, and implementations
MUST comply with its principles.
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
**Amendment procedure**:
1. Propose change with rationale.
2. Update constitution via `/speckit.constitution`.
3. Verify downstream template compatibility (Sync Impact Report).
4. A human maintainer MUST review and commit the update before
ratification takes effect.
5. Commit with version bump.
**Versioning policy**: MAJOR.MINOR.PATCH per semantic versioning.
- MAJOR: principle removal or backward-incompatible redefinition.
- MINOR: new principle or materially expanded guidance.
- PATCH: clarification, typo, or non-semantic refinement.
**Compliance review**: Every spec and plan MUST include a
Constitution Check section validating adherence to all principles.
**Version**: 1.0.3 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-03

12
apps/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Initiative Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
apps/web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --build && vite build",
"preview": "vite preview"
},
"dependencies": {
"@initiative/application": "workspace:*",
"@initiative/domain": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"vite": "^6.2.0"
}
}

3
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,3 @@
export function App() {
return <div>Initiative Tracker</div>;
}

12
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
}

14
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"jsx": "react-jsx"
},
"include": ["src"],
"references": [
{ "path": "../../packages/domain" },
{ "path": "../../packages/application" }
]
}

6
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
});

33
biome.json Normal file
View File

@@ -0,0 +1,33 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"files": {
"includes": [
"**",
"!**/dist/**",
"!.claude/**",
"!.specify/**",
"!specs/**"
]
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": {
"level": "on"
}
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"private": true,
"packageManager": "pnpm@10.6.0",
"devDependencies": {
"@biomejs/biome": "2.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0"
},
"scripts": {
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"typecheck": "tsc --build",
"test": "vitest run",
"test:watch": "vitest",
"check": "biome check . && tsc --build && vitest run"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "@initiative/application",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"@initiative/domain": "workspace:*"
}
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../domain" }]
}

View File

@@ -0,0 +1,8 @@
{
"name": "@initiative/domain",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { checkLayerBoundaries } from "../../../../scripts/check-layer-boundaries.mjs";
describe("layer boundaries", () => {
it("domain and application packages have no forbidden imports", () => {
const violations = checkLayerBoundaries();
if (violations.length > 0) {
const messages = violations.map(
(v) =>
`${v.file}:${v.line} imports "${v.importPath}" (forbidden: ${v.forbidden})`,
);
throw new Error(`Layer boundary violations:\n ${messages.join("\n ")}`);
}
expect(violations).toHaveLength(0);
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"exclude": ["src/__tests__"]
}

1550
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- "packages/*"
- "apps/*"

View File

@@ -0,0 +1,109 @@
import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
const FORBIDDEN = {
"packages/domain/src": [
"@initiative/application",
"apps/",
"react",
"react-dom",
"vite",
],
"packages/application/src": ["apps/", "react", "react-dom", "vite"],
};
/** Directories to skip when collecting files */
const SKIP_DIRS = new Set(["__tests__", "node_modules"]);
/** @param {string} dir */
function collectTsFiles(dir) {
/** @type {string[]} */
const results = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) {
results.push(...collectTsFiles(full));
}
} else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
results.push(full);
}
}
return results;
}
/**
* Check if an import path matches a forbidden module.
* "vite" should match "vite" and "vite/foo" but not "vitest".
* @param {string} importPath
* @param {string} forbidden
*/
function matchesForbidden(importPath, forbidden) {
if (forbidden.endsWith("/")) {
return importPath.startsWith(forbidden);
}
return importPath === forbidden || importPath.startsWith(`${forbidden}/`);
}
/** @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */
export function checkLayerBoundaries() {
/** @type {{ file: string, line: number, importPath: string, forbidden: string }[]} */
const violations = [];
for (const [srcDir, forbidden] of Object.entries(FORBIDDEN)) {
const absDir = join(ROOT, srcDir);
let files;
try {
files = collectTsFiles(absDir);
} catch {
continue;
}
for (const file of files) {
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = line.match(
/(?:import|from)\s+["']([^"']+)["']|require\s*\(\s*["']([^"']+)["']\s*\)/,
);
if (!match) continue;
const importPath = match[1] || match[2];
for (const f of forbidden) {
if (matchesForbidden(importPath, f)) {
violations.push({
file: relative(ROOT, file),
line: i + 1,
importPath,
forbidden: f,
});
}
}
}
}
}
return violations;
}
// Run as CLI if invoked directly
if (
process.argv[1] &&
(process.argv[1].endsWith("check-layer-boundaries.mjs") ||
process.argv[1] === new URL(import.meta.url).pathname)
) {
const violations = checkLayerBoundaries();
if (violations.length > 0) {
console.error("Layer boundary violations found:");
for (const v of violations) {
console.error(
` ${v.file}:${v.line} — imports "${v.importPath}" (forbidden: ${v.forbidden})`,
);
}
process.exit(1);
} else {
console.log("No layer boundary violations found.");
}
}

View File

@@ -0,0 +1,349 @@
# Implementation Plan: Advance Turn
**Branch**: `001-advance-turn` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-advance-turn/spec.md`
## Summary
Implement the AdvanceTurn domain operation as a pure function that
transitions an Encounter to the next combatant, wrapping rounds and
emitting TurnAdvanced / RoundAdvanced domain events. Stand up the
pnpm monorepo skeleton, Biome tooling, and Vitest test harness so
that all constitution merge-gate requirements are satisfied from the
first commit.
## Technical Context
**Node**: 22 LTS (pinned via `.nvmrc`)
**Language/Version**: TypeScript 5.8 (strict mode)
**Primary Dependencies**: React 19 (pin to major; minor upgrades
allowed), Vite 6.2
**Storage**: In-memory only (MVP baseline)
**Testing**: Vitest 3.0
**Lint/Format**: Biome 2.0.0 (exact version, single tool — no
Prettier, no ESLint)
**Package Manager**: pnpm 10.6 (pinned via `packageManager` field
in root `package.json`)
**Target Platform**: Static web app (modern browsers)
**Project Type**: Monorepo — library packages + web app
**Performance Goals**: N/A (walking skeleton)
**Constraints**: Domain package must have zero React/Vite imports
**Scale/Scope**: Single feature, ~5 source files, ~1 test file
## Constitution Check
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Deterministic Domain Core | PASS | `advanceTurn` is a pure function; no I/O, randomness, or clocks |
| II. Layered Architecture | PASS | `packages/domain``packages/application``apps/web`; strict dependency direction enforced by automated import check |
| III. Agent Boundary | N/A | Agent layer out of scope for this feature |
| IV. Clarification-First | PASS | No ambiguous decisions remain; spec fully clarified |
| V. Escalation Gates | PASS | Scope strictly limited to spec; out-of-scope items listed |
| VI. MVP Baseline Language | PASS | No permanent bans; "MVP baseline does not include" used |
| VII. No Gameplay Rules | PASS | No gameplay mechanics in plan or constitution |
| Merge Gate | PASS | `pnpm check` script runs format, lint, typecheck, test |
## Project Structure
### Documentation (this feature)
```text
specs/001-advance-turn/
├── spec.md
└── plan.md
```
### Source Code (repository root)
```text
packages/
├── domain/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── types.ts # CombatantId, Combatant, Encounter
│ ├── events.ts # TurnAdvanced, RoundAdvanced, DomainEvent
│ ├── advance-turn.ts # advanceTurn pure function
│ └── index.ts # public barrel export
├── application/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── ports.ts # EncounterStore port interface
│ ├── advance-turn-use-case.ts
│ └── index.ts
apps/
└── web/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── index.html
└── src/
├── main.tsx
├── App.tsx
└── hooks/
└── use-encounter.ts
# Testing (co-located with domain package)
packages/domain/
└── src/
└── __tests__/
└── advance-turn.test.ts
# Root config
├── .nvmrc # pins Node 22
├── pnpm-workspace.yaml
├── biome.json
├── tsconfig.base.json
└── package.json # packageManager field pins pnpm; root scripts
```
**Structure Decision**: pnpm workspace monorepo with two packages
(`domain`, `application`) and one app (`web`). Domain is
framework-agnostic TypeScript. Application imports domain only.
Web app (React + Vite) imports both.
## Tooling & Merge Gate
### Scripts (root package.json)
```jsonc
{
"scripts": {
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"typecheck": "tsc --build",
"test": "vitest run",
"test:watch": "vitest",
"check": "biome check . && tsc --build && vitest run"
}
}
```
`pnpm check` is the single merge gate: format + lint + typecheck +
test. The layer boundary check runs as a Vitest test (see below),
so it executes as part of `vitest run` — no separate script needed.
### Layer Boundary Enforcement
Biome does not natively support cross-package import restrictions.
A lightweight `scripts/check-layer-boundaries.mjs` script will:
1. Scan `packages/domain/src/**/*.ts` — assert zero imports from
`@initiative/application`, `apps/`, `react`, `vite`.
2. Scan `packages/application/src/**/*.ts` — assert zero imports
from `apps/`, `react`, `vite`.
3. Exit non-zero on violation with a clear error message.
This script is invoked by a Vitest test
(`packages/domain/src/__tests__/layer-boundaries.test.ts`) so it
runs automatically as part of `vitest run` inside `pnpm check`.
No separate `check:layer` script is needed — the layer boundary
check is guaranteed to execute on every merge-gate run.
### Biome Configuration (biome.json)
```jsonc
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"organizeImports": { "enabled": true },
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}
```
### TypeScript Configuration
- `tsconfig.base.json` at root: strict mode, composite projects,
path aliases (`@initiative/domain`, `@initiative/application`).
- Each package extends `tsconfig.base.json` with its own
`include`/`references`.
- `apps/web/tsconfig.json` references both packages.
## Milestones
### Milestone 1: Tooling & Domain (walking skeleton)
Stand up monorepo, Biome, Vitest, TypeScript project references,
and implement the complete AdvanceTurn domain logic with all tests.
**Exit criteria**: `pnpm check` passes. All 8 acceptance scenarios
green. Layer boundary check green. No React or Vite dependencies in
`packages/domain` or `packages/application`.
### Milestone 2: Application + Minimal Web Shell
Wire up the application use case and a minimal React UI that
displays the encounter state and has a single "Next Turn" button.
**Exit criteria**: `pnpm check` passes. Clicking "Next Turn" in the
browser advances the turn with correct round wrapping. The app
builds with `vite build`.
## Task List
### Phase 1: Setup (Milestone 1)
- [X] **T001** Initialize pnpm workspace and root config
- Create `pnpm-workspace.yaml` listing `packages/*` and `apps/*`
- Create `.nvmrc` pinning Node 22
- Create root `package.json` with `packageManager` field pinning
pnpm 10.6 and scripts (check, test, lint, format, typecheck)
- Create `biome.json` at root
- Create `tsconfig.base.json` (strict, composite, path aliases)
- **Acceptance**: `pnpm install` succeeds; `biome check .` runs
without config errors
- [X] **T002** [P] Create `packages/domain` package skeleton
- `package.json` (name: `@initiative/domain`, no dependencies)
- `tsconfig.json` extending base, composite: true
- Empty `src/index.ts`
- **Acceptance**: `tsc --build packages/domain` succeeds
- [X] **T003** [P] Create `packages/application` package skeleton
- `package.json` (name: `@initiative/application`,
depends on `@initiative/domain`)
- `tsconfig.json` extending base, references domain
- Empty `src/index.ts`
- **Acceptance**: `tsc --build packages/application` succeeds
- [X] **T004** [P] Create `apps/web` package skeleton
- `package.json` with React, Vite, depends on both packages
- `tsconfig.json` referencing both packages
- `vite.config.ts` (minimal)
- `index.html` + `src/main.tsx` + `src/App.tsx` (placeholder)
- **Acceptance**: `pnpm --filter web dev` starts; `vite build`
succeeds
- [X] **T005** Configure Vitest
- Add `vitest` as root dev dependency
- Create `vitest.config.ts` at root (workspace mode) or per
package as needed
- Verify `pnpm test` runs (0 tests, exits clean)
- **Acceptance**: `pnpm test` exits 0
- [X] **T006** Create layer boundary check script
- `scripts/check-layer-boundaries.mjs`: scans domain and
application source for forbidden imports
- `packages/domain/src/__tests__/layer-boundaries.test.ts`:
wraps the script as a Vitest test
- **Acceptance**: test passes on clean skeleton; fails if a
forbidden import is manually added (verify, then remove)
### Phase 2: Domain Implementation (Milestone 1)
- [ ] **T007** Define domain types in `packages/domain/src/types.ts`
- `CombatantId` (branded string or opaque type)
- `Combatant` (carries a CombatantId)
- `Encounter` (combatants array, activeIndex, roundNumber)
- Factory function `createEncounter` that validates INV-1, INV-2,
INV-3
- **Acceptance**: types compile; `createEncounter([])` returns
error; `createEncounter([a])` returns valid Encounter
- [ ] **T008** [P] Define domain events in
`packages/domain/src/events.ts`
- `TurnAdvanced { previousCombatantId, newCombatantId,
roundNumber }`
- `RoundAdvanced { newRoundNumber }`
- `DomainEvent = TurnAdvanced | RoundAdvanced`
- **Acceptance**: types compile; events are plain data (no
classes with methods)
- [ ] **T009** Implement `advanceTurn` in
`packages/domain/src/advance-turn.ts`
- Signature: `(encounter: Encounter) =>
{ encounter: Encounter; events: DomainEvent[] } | DomainError`
- Implements FR-001 through FR-005
- Returns error for empty combatant list (INV-1)
- Emits TurnAdvanced on every call (INV-5)
- Emits TurnAdvanced then RoundAdvanced on wrap (event order
contract)
- **Acceptance**: compiles; satisfies type contract
- [ ] **T010** Write tests for all 8 acceptance scenarios +
invariants in
`packages/domain/src/__tests__/advance-turn.test.ts`
- Scenarios 18 from spec (Given/When/Then)
- INV-1: empty encounter rejected
- INV-2: activeIndex always in bounds (property check across
scenarios)
- INV-3: roundNumber never decreases
- INV-4: determinism — same input produces same output (call
twice, assert deep equal)
- INV-5: every success emits at least TurnAdvanced
- Event ordering: on wrap, events array is
[TurnAdvanced, RoundAdvanced] in that order
- **Acceptance**: `pnpm test` — all tests green; `pnpm check` —
full pipeline green
- [ ] **T011** Export public API from `packages/domain/src/index.ts`
- Re-export types, events, `advanceTurn`, `createEncounter`
- **Acceptance**: consuming packages can
`import { advanceTurn } from "@initiative/domain"`
**Milestone 1 checkpoint**: `pnpm check` passes (format + lint +
typecheck + test + layer boundaries). All 8 scenarios + invariants
green.
### Phase 3: Application + Web Shell (Milestone 2)
- [ ] **T012** Define port interface in
`packages/application/src/ports.ts`
- `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)`
- **Acceptance**: compiles; no imports from adapters or React
- [ ] **T013** Implement `AdvanceTurnUseCase` in
`packages/application/src/advance-turn-use-case.ts`
- Accepts `EncounterStore` port
- Calls `advanceTurn` from domain, saves result, returns events
- **Acceptance**: compiles; imports only from `@initiative/domain`
and local ports
- [ ] **T014** Export public API from
`packages/application/src/index.ts`
- Re-export use case and port types
- **Acceptance**: consuming app can import from
`@initiative/application`
- [ ] **T015** Implement `useEncounter` hook in
`apps/web/src/hooks/use-encounter.ts`
- In-memory implementation of `EncounterStore` port (React state)
- Exposes current encounter state + `advanceTurn` action
- Initializes with a hardcoded 3-combatant encounter for demo
- **Acceptance**: hook compiles; usable in a React component
- [ ] **T016** Wire up `App.tsx`
- Display: current combatant name, round number, combatant list
with active indicator
- Single "Next Turn" button calling the use case
- Display emitted events (optional, for demo clarity)
- **Acceptance**: `vite build` succeeds; clicking "Next Turn"
cycles through combatants and increments rounds correctly
**Milestone 2 checkpoint**: `pnpm check` passes. App runs in
browser. Full round-trip from button click → domain pure function →
UI update verified manually.
## Risks & Open Questions
| # | Item | Severity | Mitigation |
|---|------|----------|------------|
| 1 | pnpm workspace + TypeScript project references can have path resolution quirks with Vite | Low | Use `vite-tsconfig-paths` plugin if needed; test early in T004 |
| 2 | Biome config format may change across versions | Low | Pinned to exact 2.0.0; `$schema` in config validates structure |
| 3 | Layer boundary script is a lightweight grep — not a full architectural fitness function | Low | Sufficient for walking skeleton; can upgrade to a Biome plugin or `dependency-cruiser` later if needed |
## Complexity Tracking
No constitution violations. No complexity justifications needed.

View File

@@ -0,0 +1,160 @@
# Feature Specification: Advance Turn
**Feature Branch**: `001-advance-turn`
**Created**: 2026-03-03
**Status**: Draft
**Input**: Walking-skeleton domain feature — deterministic turn advancement
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Advance Turn (Priority: P1)
A game master running an encounter advances the turn to the next
combatant in initiative order. When the last combatant in the round
finishes, the round number increments and play wraps to the first
combatant.
**Why this priority**: This is the irreducible core of an initiative
tracker. Without turn advancement, no other feature has meaning.
**Independent Test**: Can be fully tested as a pure state transition
with no I/O, persistence, or UI. Given an Encounter value and an
AdvanceTurn action, assert the resulting Encounter value and emitted
domain events.
**Acceptance Scenarios**:
1. **Given** an encounter with combatants [A, B, C], activeIndex 0,
roundNumber 1,
**When** AdvanceTurn,
**Then** activeIndex is 1, roundNumber is 1,
and a TurnAdvanced event is emitted with
previousCombatantId A, newCombatantId B, roundNumber 1.
2. **Given** an encounter with combatants [A, B, C], activeIndex 1,
roundNumber 1,
**When** AdvanceTurn,
**Then** activeIndex is 2, roundNumber is 1,
and a TurnAdvanced event is emitted with
previousCombatantId B, newCombatantId C, roundNumber 1.
3. **Given** an encounter with combatants [A, B, C], activeIndex 2,
roundNumber 1,
**When** AdvanceTurn,
**Then** activeIndex is 0, roundNumber is 2,
and events are emitted in order: TurnAdvanced
(previousCombatantId C, newCombatantId A, roundNumber 2) then
RoundAdvanced (newRoundNumber 2).
4. **Given** an encounter with combatants [A, B, C], activeIndex 2,
roundNumber 5,
**When** AdvanceTurn,
**Then** activeIndex is 0, roundNumber is 6,
and events are emitted in order: TurnAdvanced then RoundAdvanced
(verifies round increment is not hardcoded to 2).
5. **Given** an encounter with a single combatant [A], activeIndex 0,
roundNumber 1,
**When** AdvanceTurn,
**Then** activeIndex is 0, roundNumber is 2,
and events are emitted in order: TurnAdvanced
(previousCombatantId A, newCombatantId A, roundNumber 2) then
RoundAdvanced (newRoundNumber 2).
6. **Given** an encounter with combatants [A, B], activeIndex 0,
roundNumber 1,
**When** AdvanceTurn is applied twice in sequence,
**Then** after the first: activeIndex 1, roundNumber 1;
after the second: activeIndex 0, roundNumber 2.
7. **Given** an encounter with an empty combatant list,
**When** AdvanceTurn,
**Then** the operation MUST fail with an invalid-encounter error.
No events are emitted. State is unchanged.
8. **Given** an encounter with combatants [A, B, C], activeIndex 0,
roundNumber 1,
**When** AdvanceTurn is applied three times,
**Then** the encounter completes a full round cycle:
activeIndex returns to 0 and roundNumber is 2.
---
### Edge Cases
- Empty combatant list: AdvanceTurn MUST reject with an error.
- Single combatant: every advance wraps and increments the round.
- Large round numbers: no overflow or special-case behavior; round
increments uniformly.
## Domain Model *(mandatory)*
### Key Entities
- **Combatant**: An identified participant in the encounter. For this
feature, a combatant is an opaque identity (e.g., a name or id).
The MVP baseline does not include HP, conditions, or stats.
- **Encounter**: The aggregate root. Contains an ordered list of
combatants (pre-sorted by initiative), an activeIndex pointing to
the current combatant, and a roundNumber (positive integer,
starting at 1).
### Domain Events
- **TurnAdvanced**: Emitted on every successful AdvanceTurn.
Carries: previousCombatantId, newCombatantId, roundNumber.
- **RoundAdvanced**: Emitted when activeIndex wraps past the last
combatant. Carries: newRoundNumber.
When a round boundary is crossed, both TurnAdvanced and
RoundAdvanced MUST be emitted in that order (TurnAdvanced first).
This emission order is part of the observable domain contract and
MUST be verified by tests.
### Invariants
- **INV-1**: An encounter MUST have at least one combatant.
Operations on an empty encounter MUST fail.
- **INV-2**: activeIndex MUST always satisfy
0 <= activeIndex < len(combatants).
- **INV-3**: roundNumber MUST be a positive integer (>= 1) and MUST
only increase (never decrease or reset).
- **INV-4**: AdvanceTurn MUST be a pure function of the current
encounter state. Given identical input, output MUST be identical.
- **INV-5**: Every successful AdvanceTurn MUST emit at least one
domain event (TurnAdvanced). No silent state changes.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The domain MUST expose an AdvanceTurn operation that
accepts an Encounter and returns the next Encounter state plus
emitted domain events.
- **FR-002**: AdvanceTurn MUST increment activeIndex by 1, wrapping
to 0 when past the last combatant.
- **FR-003**: When activeIndex wraps to 0, roundNumber MUST
increment by 1.
- **FR-004**: AdvanceTurn on an empty encounter MUST return an error
without modifying state or emitting events.
- **FR-005**: Domain events MUST be returned as values from the
operation, not dispatched via side effects.
### Out of Scope (MVP baseline does not include)
- Initiative rolling or combatant ordering logic
- Hit points, damage, conditions, or status effects
- Adding or removing combatants mid-encounter
- Persistence, serialization, or storage
- UI, CLI, or any adapter layer
- Agent behavior or suggestions
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: All 8 acceptance scenarios pass as deterministic,
pure-function tests with no I/O dependencies.
- **SC-002**: Invariants INV-1 through INV-5 are verified by tests.
- **SC-003**: The domain module has zero imports from application,
adapter, or agent layers (layer boundary compliance).

View File

@@ -0,0 +1,128 @@
# Tasks: Advance Turn
**Input**: Design documents from `/specs/001-advance-turn/`
**Prerequisites**: plan.md (required), spec.md (required)
**Organization**: Tasks follow the phased structure from plan.md. There is only one user story (US1 — Advance Turn, P1), so phases map directly to the plan's milestones.
## Format: `[ID] [P?] [Story?] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[US1]**: User Story 1 — Advance Turn
- Exact file paths included in every task
---
## Phase 1: Setup (Milestone 1 — Tooling)
**Purpose**: Initialize pnpm monorepo, Biome, TypeScript, Vitest, and layer boundary enforcement
- [X] T001 Initialize pnpm workspace and root config — create `pnpm-workspace.yaml`, `.nvmrc` (Node 22), root `package.json` (with `packageManager` pinning pnpm 10.6 and scripts: check, test, lint, format, typecheck), `biome.json`, and `tsconfig.base.json` (strict, composite, path aliases)
- [X] T002 [P] Create `packages/domain` package skeleton — `packages/domain/package.json` (`@initiative/domain`, no deps), `packages/domain/tsconfig.json` (extends base, composite), empty `packages/domain/src/index.ts`
- [X] T003 [P] Create `packages/application` package skeleton — `packages/application/package.json` (`@initiative/application`, depends on `@initiative/domain`), `packages/application/tsconfig.json` (extends base, references domain), empty `packages/application/src/index.ts`
- [X] T004 [P] Create `apps/web` package skeleton — `apps/web/package.json` (React 19, Vite 6.2, depends on both packages), `apps/web/tsconfig.json`, `apps/web/vite.config.ts`, `apps/web/index.html`, `apps/web/src/main.tsx`, `apps/web/src/App.tsx` (placeholder)
- [X] T005 Configure Vitest — add `vitest` as root dev dependency, create `vitest.config.ts` at root (workspace mode or per-package), verify `pnpm test` exits 0
- [X] T006 Create layer boundary check — `scripts/check-layer-boundaries.mjs` (scans domain/application for forbidden imports) and `packages/domain/src/__tests__/layer-boundaries.test.ts` (wraps script as Vitest test)
**Checkpoint**: `pnpm install` succeeds, `biome check .` runs, `tsc --build` compiles, `pnpm test` exits 0 with layer boundary test green.
---
## Phase 2: Domain Implementation — User Story 1: Advance Turn (Priority: P1) (Milestone 1)
**Goal**: Implement the complete AdvanceTurn domain logic as a pure function with all 8 acceptance scenarios and invariant tests.
**Independent Test**: Pure state transition — given an Encounter value and AdvanceTurn action, assert resulting Encounter and emitted domain events. No I/O, persistence, or UI needed.
- [ ] T007 [US1] Define domain types in `packages/domain/src/types.ts``CombatantId` (branded/opaque), `Combatant`, `Encounter` (combatants, activeIndex, roundNumber), factory `createEncounter` enforcing INV-1, INV-2, INV-3
- [ ] T008 [P] [US1] Define domain events in `packages/domain/src/events.ts``TurnAdvanced`, `RoundAdvanced`, `DomainEvent` union (plain data, no classes)
- [ ] T009 [US1] Implement `advanceTurn` in `packages/domain/src/advance-turn.ts` — pure function `(Encounter) => { encounter, events } | DomainError`, implements FR-001 through FR-005
- [ ] T010 [US1] Write tests for all 8 acceptance scenarios + invariants in `packages/domain/src/__tests__/advance-turn.test.ts` — scenarios 18, INV-1 through INV-5, event ordering on round wrap
- [ ] T011 [US1] Export public API from `packages/domain/src/index.ts` — re-export types, events, `advanceTurn`, `createEncounter`
**Checkpoint (Milestone 1)**: `pnpm check` passes (format + lint + typecheck + test + layer boundaries). All 8 scenarios + invariants green. No React/Vite imports in domain or application.
---
## Phase 3: Application + Web Shell (Milestone 2)
**Goal**: Wire up the application use case and minimal React UI with a "Next Turn" button.
- [ ] T012 Define port interface in `packages/application/src/ports.ts``EncounterStore` port: `get(): Encounter`, `save(e: Encounter)`
- [ ] T013 Implement `AdvanceTurnUseCase` in `packages/application/src/advance-turn-use-case.ts` — accepts `EncounterStore`, calls `advanceTurn`, saves result, returns events
- [ ] T014 Export public API from `packages/application/src/index.ts` — re-export use case and port types
- [ ] T015 Implement `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — in-memory `EncounterStore` via React state, exposes encounter state + `advanceTurn` action, hardcoded 3-combatant demo
- [ ] T016 Wire up `apps/web/src/App.tsx` — display current combatant, round number, combatant list with active indicator, "Next Turn" button, emitted events
**Checkpoint (Milestone 2)**: `pnpm check` passes. `vite build` succeeds. Clicking "Next Turn" cycles combatants and increments rounds correctly.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: No dependencies — start immediately
- **Phase 2 (Domain)**: Depends on Phase 1 completion
- **Phase 3 (App + Web)**: Depends on Phase 2 completion (needs domain types and `advanceTurn`)
### Within Phase 1
- T001 must complete first (workspace and root config)
- T002, T003, T004 can run in parallel [P] after T001
- T005 depends on T001 (needs root package.json)
- T006 depends on T002 and T005 (needs domain package + Vitest)
### Within Phase 2
- T007 must complete first (types needed by everything)
- T008 can run in parallel [P] with T007 (events are independent types)
- T009 depends on T007 and T008 (uses types and events)
- T010 depends on T009 (tests the implementation)
- T011 depends on T007, T008, T009 (exports all public API)
### Within Phase 3
- T012 first (port interface)
- T013 depends on T012 (uses port)
- T014 depends on T013 (exports use case)
- T015 depends on T014 (uses application layer)
- T016 depends on T015 (uses hook)
---
## Parallel Opportunities
```text
# After T001 completes:
T002, T003, T004 — all package skeletons in parallel
# After T007 starts:
T008 — domain events can be written in parallel with types
# Independent stories: only one user story (US1), so parallelism is within-phase only
```
---
## Implementation Strategy
### MVP First (Milestone 1)
1. Complete Phase 1: Setup (T001T006)
2. Complete Phase 2: Domain (T007T011)
3. **STOP and VALIDATE**: `pnpm check` passes, all 8 scenarios green
### Full Feature (Milestone 2)
4. Complete Phase 3: App + Web Shell (T012T016)
5. **VALIDATE**: `pnpm check` passes, app runs in browser
---
## Notes
- All task IDs (T001T016) match plan.md — no scope expansion
- Single user story (US1: Advance Turn) — no cross-story dependencies
- Tests (T010) are included as specified in plan.md and spec.md
- Domain package must have zero React/Vite imports (enforced by T006)

17
tsconfig.base.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
}
}

8
tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "packages/domain" },
{ "path": "packages/application" },
{ "path": "apps/web" }
]
}

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["packages/*/src/**/*.test.ts"],
passWithNoTests: true,
},
});