diff --git a/.add/CONVENTIONS.md b/.add/CONVENTIONS.md new file mode 100644 index 000000000..438581913 --- /dev/null +++ b/.add/CONVENTIONS.md @@ -0,0 +1,10 @@ +# CONVENTIONS (living documentation — set once, kept for the whole project) + + +Language/framework: Rust 1.94 (edition 2024, MSRV enforced in CI) · runtimes: monoio (default, io_uring) + tokio (portability/CI) +Folders: production code in `src//` (directory-module convention: `mod.rs` + split read/write files); tests in `tests/` (integration, real server instances — no mocking) + `#[cfg(test)]` in `mod.rs`; benches in `benches/` (Criterion); fuzz targets in `fuzz/fuzz_targets/`. ADD task files: `.add/tasks//TASK.md` (task tests/src point INTO the repo tree, declared per-task). +Naming: snake_case files/functions · PascalCase types · SCREAMING_SNAKE consts · crate:: imports in submodules (never super::super) +Lint/format: `cargo fmt --check` · `cargo clippy -- -D warnings` (default + tokio,jemalloc feature sets) · unsafe audit (`scripts/audit-unsafe.sh`) · unwrap ratchet (`scripts/audit-unwrap.sh`) — all enforced in CI +Errors: command errors are `Frame::Error(Bytes)` with Redis-compatible `-ERR …` text on dispatch paths (no Result); library code uses `thiserror`; `anyhow` only in main.rs/tests; no unwrap/expect in library code +Architecture: thread-per-core shared-nothing shards; per-shard locks only (parking_lot, never std::sync; never held across .await); no global locks on the write path; no allocation on hot paths (command dispatch, protocol parse, event loop, io drivers); cross-shard communication via flume/SPSC messages only; dual-runtime: all runtime-specific code compiles under both feature sets; Linux-only code behind `#[cfg(target_os = "linux")]` with non-Linux fallback; files ≤1500 lines (split into directory modules) +Verification: red/green TDD per task; full local CI parity via OrbStack `moon-dev` before push; benchmark numbers from Linux VM only; new parsers get fuzz targets; new atomic state machines get loom models diff --git a/.add/GLOSSARY.md b/.add/GLOSSARY.md new file mode 100644 index 000000000..d6621f441 --- /dev/null +++ b/.add/GLOSSARY.md @@ -0,0 +1,40 @@ +# GLOSSARY (one name per concept — used everywhere: specs, contracts, code) +# Moon domain terms — evidence-grounded: src/ module tree, CLAUDE.md, README.md + +shard: a thread-per-core unit owning one keyspace slice, its event loop, DashTable, WAL writer, and blocking/pubsub registries; the unit of shared-nothing isolation (src/shard/). +SPSC mesh: the grid of single-producer/single-consumer ring channels (256 entries) carrying ShardMessage between shards; the ONLY sanctioned cross-shard mutation path (src/shard/mesh.rs). +shard event loop: the per-shard select! loop (monoio or tokio) draining I/O, SPSC messages, and the 1ms tick; nothing on it may block (src/shard/event_loop.rs). +ShardSlice: the thread-local, lock-free per-shard state access path (`with_shard`) meant to replace the locked Arc; migration in progress, gated on `is_initialized()` (src/shard/slice.rs). +ShardDatabases: the legacy Arc-shared, RwLock-per-DB store every shard can reach — the shared-nothing leak the ShardSlice migration removes (src/shard/shared_databases.rs). +Frame: the RESP protocol value enum (SimpleString, BulkString, Array, Error, …); command errors are Frame::Error, never Result (src/protocol/frame.rs). +DashTable: the per-shard extendible-hashing table (segments × 60 slots) holding CompactKey→Entry (src/storage/dashtable/). +CompactKey / CompactValue: SSO entry types — keys ≤23B and values ≤12B inline, heap beyond via tagged pointer (src/storage/compact_value.rs). +WAL (wal_v3): the per-shard write-ahead log; 1ms buffered flush, 1s fdatasync under everysec (src/persistence/wal_v3/). +AOF: append-only-file persistence layered on the WAL with rewrite/manifest machinery (src/persistence/aof/). +segment (vector): the FT.* index lifecycle unit — mutable (brute-force) → compact → immutable (HNSW + quantized codes); merged in the background (src/vector/segment/). +cross-shard latency floor: the ~1ms minimum cross-shard reply latency on monoio caused by the pending_wakers 1ms-tick relay (event_loop.rs:1049) — v1 milestone target. +hot path: command dispatch, protocol parsing, shard event loop, io drivers — the zones where allocation and locks are forbidden (CLAUDE.md "Allocations on Hot Paths"). +2026-06 architecture review: the principal-level performance review (this session) cataloguing shared-nothing violations, dispatch-floor causes, and event-loop blocking work; source of v1+ milestone priorities. + +# ADD method vocabulary (domain-standard names; bridges to legacy terms) +GOAL: the one durable outcome a project (and each milestone) runs toward — the loop's orientation anchor, declared as the lowercase `goal:` line in PROJECT.md / MILESTONE.md and surfaced by status/guide every session; distinct from a task's §1 Must (a single required behavior, not the whole-project outcome). +deep verify: the deepened Verify evidence (v20) required beyond passing tests — for a task that produced code, that every new symbol is referenced (wiring) and no new dead/unused code exists; for prose/non-code, a recorded no-skim semantic read; which path applies is resolver-judged and the engine never classifies (a rubric, not add.py). +onboarding: the install -> first-milestone path (formerly "on-ramp"). +primary flow: the solid forward path of the flow diagram — a phase starts only when its input exists (formerly "forward spine"). +cross-cutting concern: a concern running through every step rather than being one step — security, testing, observability, cost (formerly "spine / continuous concern"). +working state: everything an agent loads each session — skill router, active phase, PROJECT/MILESTONE/TASK, state.json (formerly "state surface"). +audit trail: the reference record read by people, never auto-loaded into agent context (formerly "story surface"). +method rationale: the why behind every rule — the AIDD book, loaded on demand, never duplicated (formerly "trust layer"). +failing-first suite: the test suite written before code, confirmed red for the right reason — a missing implementation (formerly "red safety net"). +non-functional review: the deliberate post-evidence check of what tests rarely catch — concurrency, security, architecture (formerly "blind-spot checks"). +change scope: the files a locked run may and may not touch (formerly "touch-boundary"; the XML prompt tag keeps its name). +automated quality gate: the evidence-based Verify resolver under autonomy auto — may auto-PASS on complete evidence; security always escalates (formerly "evidence auto-gate"). +autonomy level: the per-task Verify resolver setting — auto (default) or conservative; declared in the TASK.md header, human-reviewed at the freeze (formerly "autonomy dial"). +living documentation: the durable project artifacts — conventions, glossary, frozen contracts — that outlive any particular code (formerly "survivor layer"). +scope level: the granularity a decision lives at — intake level (request -> versioned scope), milestone level, setup/foundation level, task level (formerly "altitude"). +baseline approval: the one human gate that freezes the AI-drafted foundation, first scope, and first contract together — runs as `add.py lock` (formerly "the lock-down"). +lesson learned: a single learning a loop produces, tagged by the competency it improves — the `- [DDD · open]` grammar and deltas.md/`add.py deltas` machine names stay (formerly "competency delta"). +lowest-confidence flag: the AI's ranked declaration of the 1–2 points most likely to be wrong in what a human is asked to approve — each with why + cost-if-wrong; the ⚠ glyph keeps its name as the machine marker (formerly "least-sure flag"). +decision point: a stop for human judgment — the contract-freeze approval, an escalated verify gate, intake confirmation, milestone close; the machine names seam (--json owner enum, decide key) and seam-audit (CI job) keep their names (formerly "seam"). +retrospective consolidation: gathering confirmed lessons learned at milestone close and writing them append-only into the versioned foundation — human-confirmed, never self-approved; the machine names fold.md, the folded status, and add.py deltas keep their names (formerly "the fold / fold ritual"). +specification bundle: a task's spec, scenarios, contract, and failing tests drafted as one piece and approved by a person once at the contract freeze (formerly "the one-approval front"). diff --git a/.add/MODEL_REGISTRY.md b/.add/MODEL_REGISTRY.md new file mode 100644 index 000000000..1386b6a42 --- /dev/null +++ b/.add/MODEL_REGISTRY.md @@ -0,0 +1,6 @@ +# MODEL_REGISTRY (which AI wrote this project — for reproducibility & audit) + +Model: Claude (Anthropic) — Claude Code CLI +Version: claude-fable-5 (adopted at ADD onboarding; earlier code AI-assisted with prior Claude versions per git history) +Adopted: 2026-06-11 +Notes: pre-ADD codebase (v0.3.0) was substantially AI-assisted (Claude Code review runs on PRs per CI). Re-run the playbook golden-cases before changing this. diff --git a/.add/PROJECT.md b/.add/PROJECT.md new file mode 100644 index 000000000..f56b3a9a3 --- /dev/null +++ b/.add/PROJECT.md @@ -0,0 +1,44 @@ +# PROJECT — living documentation (cross-milestone context) + +> The durable foundation that outlives every milestone and feeds context into each +> TDD⇄ADD loop. Read this FIRST in any session. Keep it lean — one screen, not a +> manual. Map to the AIDD diagram: Domain = DDD · Spec = SDD (living document) · +> UI/UX = UDD. When a loop reveals a gap here, come back and update this file — +> that is the re-entrant arrow from the engine down to the foundation. + +slug: moon · stage: production · updated: 2026-06-11 +goal: a Redis-compatible server whose thread-per-core architecture measurably out-scales Redis on multi-core hardware — without sacrificing protocol compatibility or durability semantics + +--- + +## Domain (DDD) — the language and the boundaries + +- Core concepts: Shard (thread-per-core owner of a keyspace slice) · Frame (RESP protocol unit) · DashTable (per-shard hash storage) · CompactKey/CompactValue (SSO entry types) · SPSC mesh (cross-shard dispatch channels) · WAL/AOF (per-shard durability log) · Segment (vector index lifecycle unit: mutable → immutable) +- Bounded contexts / modules: `protocol/` (RESP parse/serialize) · `shard/` (event loop, dispatch, coordinator) · `storage/` (dashtable, eviction, tiered) · `persistence/` (wal_v3, aof, page_cache) · `vector/` + `command/vector_search/` (FT.* engine) · `command/` (dispatch tables) · `io/` (uring driver) · `runtime/` (monoio/tokio abstraction) · cross-cutting: `acl/ pubsub/ replication/ cluster/ blocking/ transaction/ scripting/ tracking/` +- Invariants that must always hold: + - Shared-nothing per shard: no global locks on the command write path (CLAUDE.md "Lock Handling") — **currently violated in ≥8 places (2026-06 architecture review)** + - Malformed client input never crashes the server (`parse_frame_zerocopy` returns `Frame::Null`) + - No new `unsafe` without approval + `// SAFETY:` comment (UNSAFE_POLICY.md) + - No alloc in command dispatch / protocol parse / shard event loop / io drivers + - All runtime-specific code compiles under both `runtime-monoio` and `runtime-tokio` + +## Spec / Living Document (SDD) — what we are building, now +- Active milestone → `.add/milestones/v1-shared-nothing/MILESTONE.md` (see `add.py status`) +- Frozen contracts (living docs): RESP2/RESP3 wire compatibility with Redis (external, immutable); CI matrix (fmt, clippy ×2, tests ×2, MSRV 1.94, unsafe/unwrap audits, fuzz) +- Settled vs still open: settled — thread-per-core + SPSC mesh architecture, monoio default on Linux. Open — sub-linear multi-shard scaling (root causes mapped in 2026-06 review: leaky shared-nothing + 1ms monoio wake floor) + +## Users (UDD) — UI/UX: design before code +- No UI — surface is the **Redis wire protocol** (RESP2/RESP3) plus CLI flags (`--port --shards --appendonly --dir …`) and INFO/Prometheus metrics. +- Primary users & jobs: backend engineers replacing Redis for higher throughput/lower memory per node; benchmark parity tooling (`scripts/bench-*.sh`, `redis-benchmark`). +- Core flows: connect → AUTH/HELLO → command pipeline → responses; ops flows: persistence reload, replication, FT.* vector search. +- UI states: error surface = RESP error frames with Redis-compatible messages (`-ERR …`); never a crash, never a hang. +- Design source of truth → README.md (architecture diagram + command reference). + +## Key Decisions (append-only) +| date | decision | why | outcome | +|------|----------|-----|---------| +| pre-ADD | thread-per-core, monoio/io_uring default on Linux | syscall/wakeup cost dominates at high QPS | shipped v0.3.0 | +| pre-ADD | per-shard WAL, no global append lock | Redis single-AOF serializes at depth | AOF advantage grows with pipeline depth | +| pre-ADD | SSO CompactKey(23B)/CompactValue(12B) | per-key memory vs naive Arc | lower RSS than Redis per key | +| 2026-06-11 | adopt ADD; v1 milestone = restore shared-nothing integrity + remove cross-shard latency floor (review priorities 1·2·5) | 2026-06 architecture review: these two themes explain sub-linear multi-shard scaling | pending | +| 2026-06-11 | FT.SEARCH off-event-loop + WAL group commit deferred to v2 | different themes (event-loop blocking; durability); keep v1 one outcome | recorded in v1 Out list | diff --git a/.add/SETUP-REVIEW.md b/.add/SETUP-REVIEW.md new file mode 100644 index 000000000..7c96c185f --- /dev/null +++ b/.add/SETUP-REVIEW.md @@ -0,0 +1,19 @@ +# SETUP REVIEW — moon + +production · brownfield · drafted by claude-fable-5 @ 2026-06-11 + +| # | Decision | Lands in | Tag | Why / Evidence | +|---|----------|----------|-----|----------------| +| 1 | QW4 (per-shard command counters) assumes the Prometheus `counter!` path can split per-shard, and that scrape-read aggregation freshness is acceptable | first-contract | `guessed` | The `metrics` crate's recorder API may not register per-shard handles cleanly (src/admin/metrics_setup.rs uses global macros). Fallback shrinks the win to the bespoke `TOTAL_COMMANDS` atomic only. | +| 2 | QW8 (client registry off the per-batch path) assumes CLIENT LIST tolerates on-change/atomic metadata updates instead of per-batch write-lock updates | first-contract | `guessed` | No test pins CLIENT LIST `cmd`/`age` freshness; redis-tooling expectations inferred. Fallback: per-connection atomics (still lock-free). | +| 3 | v1 milestone scopes review priorities 1·2·5 together and defers FT.SEARCH offload + WAL group commit to v2 | scope | `guessed` | My sizing judgment: one outcome (restore shared-nothing + remove latency floor); priorities 3·4 are different themes. You may prefer FT.SEARCH offload sooner if vector workloads are imminent. | +| 4 | Exit criterion "cross-shard SET/GET p99 < 1ms on monoio" is achievable once the 1ms waker relay is replaced | scope | `guessed` | Inferred from event_loop.rs:1049 comments; no prototype of the eventfd wake exists yet. If wrong: criterion relaxes to "p99 materially below current baseline". | +| 5 | First task = the quick-wins batch (8 items in one task) rather than 8 micro-tasks or starting with the ShardSlice migration | scope | `guessed` | Items are mechanical and share one bench baseline; doing them first de-noises the bigger tasks' measurements. ShardSlice (your priority 1) lands as task 3 with the frozen contract named in MILESTONE.md. | +| 6 | Project goal sentence ("out-scales Redis on multi-core hardware …") | PROJECT.md | `guessed` | Inferred from README positioning + bench tooling; the repo states no explicit mission sentence. | +| 7 | The 8 quick-win code targets exist as described (Mutex'd WAL sender, RwLock'd offsets, global TOTAL_COMMANDS, per-byte backlog loop, Vec::remove(0), unpruned key maps, per-batch registry write, missing TCP_NODELAY) | first-contract | `evidence-grounded` | Each verified by direct read this session: shared_databases.rs:159 · spsc_handler.rs:3089 + state.rs:138 · metrics_setup.rs:401 · backlog.rs:35 · uring_handler.rs:346 · vector/store.rs:192 (zero `.remove` calls) · client_registry.rs:80 + handler_sharded/mod.rs:1868 · grep shows set_nodelay only in moon-bench.rs. | +| 8 | Domain map, invariants, conventions, glossary terms | PROJECT.md / CONVENTIONS.md / GLOSSARY.md | `evidence-grounded` | CLAUDE.md (coding rules, gotchas), README.md, src/ module tree, Cargo.toml. | +| 9 | Dependency allowlist | dependencies.allowlist | `evidence-grounded` | Cargo.toml `[dependencies]` + feature-gated crates, v0.3.0. | +| 10 | Stage = production (full rigor, observe loop) | foundation | `evidence-grounded` | v0.3.0 shipped with signed release assets, CI matrix, fuzz + safety audits (git history, .github/workflows). | +| 11 | Behavior parity is a frozen contract for all v1 tasks (RESP bytes, INFO fields, 132 consistency tests) | scope | `evidence-grounded` | scripts/test-consistency.sh + CI define the parity surface; v1 is perf-only by the user's request. | + +Sign: confirm in chat → the agent runs `add.py lock --by "Tin Dang"` (typing it yourself works too) diff --git a/.add/dependencies.allowlist b/.add/dependencies.allowlist new file mode 100644 index 000000000..5f23555ef --- /dev/null +++ b/.add/dependencies.allowlist @@ -0,0 +1,66 @@ +# dependencies.allowlist — one package per line; CI rejects anything not listed. +# Defeats the AI "invented a plausible package name" supply-chain risk. +# evidence-grounded: Cargo.toml [dependencies] (+ optional/feature-gated crates) as of v0.3.0. + +atoi +bumpalo +bytes +memchr +smallvec +thiserror +mimalloc +crc32c +crossbeam-utils +flume +atomic-waker +tokio +tokio-util +clap +tracing +tracing-subscriber +anyhow +futures +ordered-float +phf +rand +crc16 +crc32fast +arc-swap +parking_lot +itoa +xxhash-rust +ringbuf +core_affinity +once_cell +mlua +sha1_smol +sha2 +hex +uuid +ctrlc +metrics +metrics-exporter-prometheus +rustls +rustls-pemfile +aws-lc-rs +tokio-rustls +monoio-rustls +roaring +serde +serde_json +socket2 +memmap2 +lz4_flex +dashmap +# feature-gated / platform +monoio +io-uring +libc +jemallocator +cudarc +# dev/test/bench +criterion +loom +proptest +tempfile +redis diff --git a/.add/docs/00-introduction.md b/.add/docs/00-introduction.md new file mode 100644 index 000000000..e915aa134 --- /dev/null +++ b/.add/docs/00-introduction.md @@ -0,0 +1,46 @@ +# 00 · The shift: why AIDD exists + +[Contents](./README.md) · Next: [01 Core principles →](./01-principles.md) + +--- + +## Code became cheap + +For the whole history of software, writing code was the slow, expensive, central act. Methodologies — waterfall, agile, and the rest — were arrangements for managing that expensive act: how to plan it, divide it, review it, and ship it. + +AI changed the cost. An agent can now produce a working module in the time it takes to describe it. The marginal cost of *writing* a piece of code, and of *re-writing* it, has fallen close to zero. + +When the cost of one activity collapses, value moves to whatever is still scarce. Three things remain scarce: + +1. **Validated decisions** — knowing what should be built, and being right about it. +2. **Stable contracts** — the agreed interfaces and data shapes that everything else depends on. +3. **Verification capacity** — the rate at which people can confirm that what was produced is actually correct. + +AIDD is a development method organized around protecting those three things, because they are now where the difficulty lives. + +## The failure mode AIDD prevents + +The naïve way to use an AI agent is to describe a feature in a sentence and accept whatever it returns. This works for a throwaway script and fails for real software, for one reason: **an AI agent is fast in whatever direction it is pointed.** + +If the direction is vague, the agent does not slow down and ask. It produces a confident, plausible, complete-looking result that is subtly wrong — built on an assumption you never made, missing an edge case you never stated. Because it looks finished, the error survives a quick read and surfaces later, when it is expensive to fix. + +Speed in the wrong direction is not progress; it is faster waste. The entire purpose of AIDD is to fix the direction *before* turning on the speed. + +## Where value moves — and what that means for you + +If writing code is no longer the scarce skill, then a software person's value is no longer "can write code." It is two new things: + +- **Direction** — turning a fuzzy need into an unambiguous, buildable definition. +- **Verification** — establishing, through evidence, that the result is correct and safe. + +This is not a smaller job than coding; it is a harder one. It is the part of engineering that was always the real work, now made explicit because the typing has been automated away. + +## What this book gives you + +The rest of the book is the practical consequence of the shift: + +- A **flow** (Part II) that front-loads direction and back-loads AI execution, with verification built in. +- An **operating manual** (Part III) for running that flow across stages, roles, and risk levels. +- **Reference material** (Part IV) — templates, prompts, and a fully worked example — so the method is concrete from day one. + +> **The thesis in one line.** Build the right thing (direction), prove it is right (verification), and let the AI do the building in between. diff --git a/.add/docs/01-principles.md b/.add/docs/01-principles.md new file mode 100644 index 000000000..600edc6f5 --- /dev/null +++ b/.add/docs/01-principles.md @@ -0,0 +1,71 @@ +# 01 · Core principles + +[← 00 The shift](./00-introduction.md) · [Contents](./README.md) · Next: [02 The flow →](./02-the-flow.md) + +Everything in this book follows from a small set of principles. If a practice ever seems arbitrary, trace it back to one of these. + +--- + +## 1. Direction before speed + +An AI agent accelerates in whatever direction it is given. Therefore the direction must be fixed before the acceleration begins. In practice this means the early, human-led steps of the flow are not optional preamble — they are the steering, and the build step is the engine. You do not start the engine until the wheel is set. + +**Consequence:** the specification, scenarios, and contract come *before* any code, every time. + +## 2. Trust through evidence, not inspection + +AI output is often wrong in ways that read as correct. You cannot establish correctness by reading the code and judging it plausible. You establish it by defining, in advance, what "correct" means — as automated tests — and confirming the code satisfies them, then checking by hand only the narrow set of things tests cannot catch. + +**Consequence:** tests are written *before* the implementation, and a feature is trusted because its tests pass, not because someone reviewed it and liked it. + +## 3. The artifacts survive; the code is disposable + +The durable assets of a project are the decisions and agreements: the specification, the scenarios, the contract, the tests. The code is merely one implementation that satisfies them and can be regenerated at will. Protect the artifacts; treat the code as replaceable. + +**Consequence:** effort goes into keeping contracts and specs stable and clear, not into preserving particular code. Metrics that count code volume or reuse measure the wrong thing. + +## 4. The loop is re-entrant, not a waterfall + +The flow has an order, but it is not a one-way march. Any step may reveal a gap in an earlier one — and when it does, you return to that earlier step, fix the artifact, and come forward again. The specification is a living document, not a frozen contract signed once. + +**Consequence:** discovering a missing rule during the build is the method working, not failing. The only true one-way door is the frozen interface contract, and even that reopens through a deliberate change request. + +## 5. Trust is earned per scope, not granted globally + +How much you let the AI do is not a single switch. It is a setting that lives *per scope*, and it can differ from one part of the system to another. A well-tested, low-risk area may run at full autonomy while a new, high-risk one is held back. + +The *default starting point* is a deliberate choice. A team that has built up evidence and tooling may **start a scope at auto** — the AI drafts the specification bundle, a human approves the frozen contract once, and the build runs and auto-gates on evidence — and *lower to conservative* wherever risk is high. (An earlier formulation started every scope conservative and made autonomy the earned exception; it is the same control either way — what differs is which end you default to.) Two things never move with the default, whichever way it points: the contract-freeze decision point stays human (the AI never freezes the interface it then builds against), and a high-risk scope is always lowered, never auto-run. + +**Consequence:** autonomy is a per-scope setting you choose deliberately and can lower at any time; high-risk scope is held to a human gate regardless of the default (see [11 Governance](./11-governance.md)). + +## 6. You cannot move faster than you can verify + +When an agent produces more than the team can review, the excess is not speed — it is unreviewed risk accumulating. Verification capacity is the real ceiling on throughput. + +But *verification* is not the same as *human reading*. The ceiling is what you can trust to a recorded standard, and automated verification raises it: a passing test suite, a contract check, an adversarial verifier are all verification, and they scale in a way human review cannot. This is only principle 2 taken to its limit. What automation cannot cover is the residue principle 2 names — the narrow set tests miss: security, concurrency, and architecture. That residue stays at human speed. So the rule sharpens: you may move as fast as your *automated* verification carries you, and no faster on the part only a human can check. + +**Consequence:** if AI output outpaces verification, the correct response is to strengthen the automated checks or reduce the AI's autonomy — never to rush or skip. More autonomy is earned by more verification, not by a lower bar. + +## 7. No silent skips + +Every checkpoint resolves explicitly. A step is either passed, or passed with a recorded and signed acceptance of a known risk, or stopped. Nothing is quietly waved through. + +An *automated* pass is still an explicit pass, not a skip — provided it records an outcome and escalates what it cannot judge. A gate may be resolved by evidence rather than by a person when that evidence is sufficient and the result is logged to an accountable owner: a named run, against a recorded standard, is as accountable as a signature. The line between a pass and a skip is the recorded outcome, not who signed it. The exception is absolute: security always escalates to a human and is never auto-passed — a security finding is a hard stop, whatever the evidence says. + +**Consequence:** every gate produces a recorded outcome with an accountable owner — a person, or a named automated run — and security always stops for a person (see [11 Governance](./11-governance.md)). + +## 8. Tool-agnostic by construction + +The instructions you give the AI are plain text that reference files in the repository, not commands tied to one product. Enforcement of the gates lives in your build pipeline, not in the agent. This keeps the method portable: the agent is replaceable; the method is not. + +**Consequence:** the same project works whether the team uses one AI coding tool or another, and switching tools changes nothing structural. + +## 9. Two layers: the working state you load, the audit trail you reference + +A method that fills the context window with its own documentation defeats itself — the agent rots before it reaches the work. So ADD keeps two documentation layers and never loads both. The **working state** is everything an agent loads to do the work each session: the `add` skill itself (its router `SKILL.md` and the one phase currently in play) together with the lean, current operational docs — `PROJECT.md` (the foundation), the active `MILESTONE.md`, the active `TASK.md`, and `state.json`. The **audit trail** is this book plus the records behind it: the whole method, read once by a person to understand and trust ADD, and thereafter **never auto-loaded** into agent context — only referenced by a pointer. Depth lives in the audit trail; leanness is enforced on the working state; they never compete for the same tokens. + +**Consequence:** the book can be as rich as trust requires without costing a single runtime token, while the loaded surface stays small enough never to rot. It is why the guideline block in `CLAUDE.md`/`AGENTS.md` *points* to `add.py status` and `PROJECT.md` rather than copying them. + +--- + +> **The principles, compressed.** Steer before you accelerate. Trust evidence, not impressions. Keep the decisions, throw away the code. Loop freely, but never skip silently. Load the State; reference the Story. Grant the AI only as much autonomy as you can verify. diff --git a/.add/docs/02-the-flow.md b/.add/docs/02-the-flow.md new file mode 100644 index 000000000..0de1c8d91 --- /dev/null +++ b/.add/docs/02-the-flow.md @@ -0,0 +1,100 @@ +# 02 · The flow, and what is disposable + +[← 01 Core principles](./01-principles.md) · [Contents](./README.md) · Next: [03 Step 1 Specify →](./03-step-1-specify.md) + +--- + +## The flow + +AIDD is one repeatable flow of **seven steps**: six build the feature — Specify → Scenarios → Contract → Tests → Build → Verify — and the seventh, **Observe**, feeds what production teaches back into the next Specify. In the default flow the AI drafts the specification bundle (steps 1–4) and a person approves it **once**, at the contract freeze; the AI performs the Build; and Verify is resolved on evidence under `autonomy: auto`, with a person owning any residue. (See [11 Governance](./11-governance.md) for the autonomy level and the one-approval decision point.) + +![The ADD flow — a solid primary flow Specify→Scenarios→Contract→Tests→Build→Verify→Observe, with dashed backward-correction arrows (any phase may return to an earlier one), a Tests⇄Build red/green engine, and Observe looping back to the next Specify](./add-flow.png) + +```mermaid +flowchart LR + S1["1 Specify
the rules"] --> S2["2 Scenarios
pass/fail cases"] + S2 --> S3["3 Contract
freeze the shape"] + S3 --> S4["4 Tests
failing-first (red)"] + S4 --> S5["5 Build
AI writes code"] + S5 --> S6["6 Verify
evidence + checks"] + S6 --> OBS["Observe
in production"] + S5 -. "red / green engine" .-> S4 + S6 -. "evidence fails → back to Build" .-> S5 + S5 -. "a missing rule → back to Specify" .-> S1 + OBS -. "what you learn becomes the next spec" .-> S1 + classDef human fill:#FAEEDA,stroke:#BA7517,color:#633806; + classDef decision fill:#E1F5EE,stroke:#0F6E56,color:#04342C; + classDef machine fill:#E6F1FB,stroke:#185FA5,color:#042C53; + class S1,S2 human; + class S3,S4 decision; + class S5,S6 machine; +``` + +> **Solid arrows are the primary flow** — you never start a phase before its input exists (forward-skip forbidden). **Dashed arrows are backward correction** — any phase may return to an earlier one to repair its artifact (the long loop, Observe → Specify, is the same rule at milestone scale). The tight Tests ⇄ Build cycle is the per-feature red/green engine. + +```text + human-led ─────────────────►│◄─────────── machine-led ──► human verify + 1 Specify → 2 Scenarios → 3 Contract → 4 Tests ⇄ 5 Build → 6 Verify + ▲ (freeze) └red/green┘ (AI) (people) + ╎ │ + ╎╴╴ backward correction (dashed): any phase may return to ╴╴╴┤ + ╎ an earlier one — e.g. Build exposes a missing rule │ + │ │ + │ observe in production ◄──────────────────┘ + │ │ + └─────────────────────────┘ becomes the next Specify +``` + +The shape is deliberate: the human-led steps establish direction, a frozen contract forms the decision point in the middle, and the AI-led build runs fast and safely on the far side because everything it needs is already fixed. + +> **What changed in v7 (the diagrams above show the structural flow, which is unchanged).** The *steps* and their order are exactly as drawn — only **who resolves them** moved. The AI now drafts the whole specification bundle (steps 1–4) and a person approves it **once**, at the contract freeze (not a sign-off at each step); and **Verify is auto-gated on evidence** under `autonomy: auto` (the default), escalating security — always a `HARD-STOP` — and other residue to a person. Lower the autonomy level to `conservative` to keep a human at the Verify gate. See [11 Governance](./11-governance.md). + +## Why the order is the order + +Each step produces exactly one artifact, and each artifact is the input to the next step. The order is not a preference; it is a dependency chain. + +| Step | Produces | Which is needed by | +|------|----------|--------------------| +| 1 Specify | the rules | scenarios, and everything after | +| 2 Scenarios | pass/fail cases | the tests | +| 3 Contract | the fixed shape | the tests and the build | +| 4 Tests | the failing-first suite | the build and the verification | +| 5 Build | the code | the verification | +| 6 Verify | a trusted, releasable change | the release and the next loop | + +The single rule of discipline follows directly: **do not begin a step until the previous artifact exists.** Skipping forward means the AI builds against a guess. + +The flow runs in two directions under two rules that never conflict. **Backward correction is always allowed:** any phase may send you back to an earlier one to repair its artifact — a failing Build that exposes a missing rule sends you back to Specify, and that is the loop working ([principle 4](./01-principles.md)), not a failure. **Forward-skipping is forbidden:** you never start a phase before its input artifact exists. Correct backward freely; never skip forward. + +**`done` is terminal — except via the recorded reopen.** Backward correction moves a *live* task; a task at `done` has already passed its gate. The one way back from `done` is the recorded `reopen` action (`add.py reopen --to --reason "..."`): it returns the task to an earlier phase, resets the gate, and writes down *why* — so a done verdict is never quietly un-done. This is the same backward-correction rule, made explicit at the one state where it would otherwise be bypassed silently. + +## Who does what + +| Step | Person's job | AI's job | +|------|--------------|----------| +| 1 Specify | confirm the rules (part of the one approval) | draft; list assumptions to confirm | +| 2 Scenarios | confirm what "correct" looks like (part of the one approval) | draft scenarios | +| 3 Contract | **approve & freeze the whole bundle (§1–§4) once — the decision point** | draft the contract and mocks | +| 4 Tests | confirm the targets (part of the one approval) | draft the failing tests | +| 5 Build | direct in small batches | implement until tests pass | +| 6 Verify | own the residue (security · concurrency · architecture); approve when `conservative` | gather evidence; **auto-PASS on complete evidence** under `autonomy: auto` | +| 7 Observe | read the signal; consolidate confirmed deltas into PROJECT.md | run behind a flag; emit lessons learned | + +**What the human sees when it is their turn — the decision arc.** Whenever the flow stops for the human — the baseline approval that ends setup, the contract-freeze decision point and an escalated verify gate within each task, and the wider decision points of the loop (intake · scope · milestone close · stage graduation) — the AI opens its report with the **decision arc**: three engine-sourced lines — `goal:` the milestone goal the work serves · `done:` the proven progress toward it · `plan:` what comes next. The arc renders first, above the report's summary, so the human confirms with sight of the whole trajectory rather than a local snapshot. It is presentation only — it never adds a gate or changes an outcome. See [Appendix C](./appendix-c-glossary.md). + +## What survives, and what is disposable + +This is the idea that most distinguishes AIDD from older practice. + +**The artifacts are the durable asset.** The specification, the scenarios, the contract, and the tests capture decisions and meaning. They are what you protect, version, and carry forward. + +**The code is disposable.** It is one implementation that satisfies the artifacts. If a better approach appears, or the AI model improves, the code can be regenerated against the same artifacts without loss. + +A practical test of whether a team has absorbed this: ask what they would be upset to lose. If the answer is "the code," they are still working the old way. If the answer is "the contracts and the tests," they are working in AIDD. + +> **Do:** invest in clear, stable specs, contracts, and tests. +> **Don't:** measure progress by how much code was generated or reused — that counts the cheap, disposable thing. + +## How the rest of Part II is organized + +Each of the next seven chapters takes one step (and then the loop) and gives it the same treatment: its purpose, who does it, the artifact it produces, the AI prompt that drives it, the exit check that says it is done, and what to do when that check fails. The running example continues throughout. diff --git a/.add/docs/03-step-1-specify.md b/.add/docs/03-step-1-specify.md new file mode 100644 index 000000000..ed9b2de42 --- /dev/null +++ b/.add/docs/03-step-1-specify.md @@ -0,0 +1,117 @@ +# 03 · Step 1 — Specify + +[← 02 The flow](./02-the-flow.md) · [Contents](./README.md) · Next: [04 Step 2 Scenarios →](./04-step-2-scenarios.md) + +> **Purpose:** state, in plain language, what the feature must do and what it must reject, with no ambiguity left for the AI to resolve by guessing. +> **Produces:** `SPEC.md` for the feature. +> **How it works — co-specification:** AI and human **brainstorm the shape together**; the AI drafts; the **human validates, with the AI's advice.** The decisive advice is a *lowest-confidence flag* — the AI names the one or two things most likely to be wrong, so the human's attention lands where it matters. The human owns the decision; the AI owns surfacing what it does not yet know. + +--- + +## Why this step is first + +The specification is the description the AI will build from. Every other artifact descends from it. Anything vague here does not stay vague — it becomes a concrete wrong guess in the code, discovered late. The cheapest moment to remove an ambiguity is now, in a sentence, before anything depends on it. + +There is also a diagnostic value: **if you cannot write the spec, you do not yet understand the feature well enough to build it.** The inability to specify is information, not an obstacle to push past. + +## Co-specification — how the spec gets made + +A specification is not dictated by one side. It is made in three moves: + +1. **Diverge — brainstorm by both.** Before drafting, the AI surfaces the *decision space*: the two or three genuine ways to frame the feature, and the open questions it would otherwise resolve by guessing. You react — add, kill, redirect. This is the brainstorm, and it lives in the conversation, not in a new document. +2. **Converge — the AI drafts, and ranks its own uncertainty.** The AI writes the spec below, then ranks where its confidence is lowest. It does not hand you a flat list of equal-looking assumptions to nod through; it tells you *where it is most likely wrong, and what that would cost.* +3. **Validate — you decide, with the AI's advice.** You read the ranked uncertainty first, then confirm, correct, or send it back. Your approval is real because your attention was aimed. + +The brainstorm leaves a *light trace, not a document.* What you chose becomes a rule; what you weighed and dropped becomes a one-line **`Framings weighed:`** note; what stayed genuinely uncertain becomes a **lowest-confidence flag**. Nothing new to maintain — the residue lands in the spec you were writing anyway. + +## What a good specification contains + +Four parts, kept short: + +1. **Must** — the behaviors the feature is required to perform. +2. **Reject** — the inputs or situations it must refuse, each paired with a named error. +3. **After** — the state that is true once it succeeds (what changed). +4. **Assumptions — lowest-confidence first** — the things you are taking for granted, **ranked so the most-likely-wrong come first.** The top one or two carry a `⚠` flag with *why it is uncertain* and *what it costs if wrong*; the rest are the low-stakes tail. A spec with genuinely nothing uncertain still names its single biggest risk, however small — the AI never claims a blank mind. + +Naming the errors matters. "Reject bad amounts" is an instruction to guess; `amount <= 0 -> "amount_invalid"` is a rule that produces a testable scenario and a defined contract response. + +## Template + +``` +# SPEC.md +Feature: +Framings weighed: (chosen) · · +Must: + - +Reject: + - -> "" +After: + - +Assumptions — lowest-confidence first: + ⚠ — lowest confidence because ; if wrong: + - [x] +``` + +## ▶ Example + +``` +Feature: Transfer money between my own accounts +Framings weighed: synchronous single-currency transfer (chosen) · queued transfer · multi-currency with FX +Must: + - move an amount from one of my accounts to another of mine + - amount > 0 + - source and destination are different accounts + - source has enough balance +After: + - source balance -= amount, destination balance += amount +Reject: + - amount <= 0 -> "amount_invalid" + - source == destination -> "same_account" + - balance < amount -> "insufficient_funds" + - account not mine -> "forbidden" +Assumptions — lowest-confidence first: + ⚠ same currency only (no FX) in v1 — lowest confidence because the ticket never said; if wrong: the whole amount/rounding model changes and this contract is wrong + - [x] no daily limit in v1 — confirmed: out of scope for v1 +``` + +The `Framings weighed:` line shows what was considered and dropped, so the chosen shape is a *decision*, not a default. The `⚠` line is the one the stakeholder reads first: the assumption most likely to be wrong and most expensive to get wrong. The flat `[x]` line is real but low-stakes. A reviewer can now spend their attention where it pays. + +## The AI's role here + +Use the AI to **open the space and then narrow it honestly.** First it brainstorms the genuine framings with you (diverge). Then it drafts the spec from whatever raw material you have — a ticket, an interview, a contract document — listing every assumption it had to make, **ranked lowest-confidence first**, and flagging the one or two it is least confident in with *why* and *what it costs if wrong*. Its instinct is to fill gaps silently and present a confident wall; the method forces those gaps into the open, and forces the confident wall to declare its own soft spots. See `playbook/1_specify.md` in [Appendix B](./appendix-b-prompts.md). + +The defining instruction: *if a requirement is unclear, ask — do not resolve it by guessing — and of the things you must assume, say plainly where your confidence is lowest.* + +## Common mistakes + +- **Stating only the happy path.** The "Reject" list is where most real complexity lives; an empty one usually means it has not been thought through. +- **Free-text errors.** Errors must be named codes, not sentences, so they can become scenarios and contract responses. +- **Hidden assumptions.** If an assumption is not written down, it is not confirmed — it is a future bug with a delay timer. +- **A flat list of "confirmed" assumptions.** Eight equal-looking ticks invite a reflex approval. Rank them; flag the one or two that are load-bearing. An unranked list hides the risk inside the noise. + +## Exit check + +A spec is done when: + +- [ ] Every required behavior is stated explicitly. +- [ ] Every rejection has a named error code. +- [ ] The success state-change is described. +- [ ] The assumptions are ordered lowest-confidence first, and the one or two `⚠` flags carry *why* + *cost* — or, for genuinely trivial scope, an honest "none material" that still names the single biggest risk. + +The shift from older practice: you no longer pre-confirm every assumption to advance. You confirm that the AI has *ranked* its uncertainty and that you have *engaged the top of the rank.* Stated honestly: the flag makes a genuine review cheap and a lazy one visibly negligent — it cannot force the read. That is the most a lightweight check can buy. + +## If the check fails + +If you cannot state a rule clearly, the feature is not ready to build. Stop, take the question to whoever owns the requirement, and resolve it. Do not let the AI proceed on an unresolved point — that is the exact failure the whole method exists to prevent. + +--- + +## The one approval, and where the flag really lands + +In the one-approval flow, you do not approve the spec alone — you approve the whole frozen bundle (spec, scenarios, contract, tests) once, at the contract freeze. So the lowest-confidence flag is **bundle-wide**: at that single decision point the AI leads with *"of everything I'm asking you to freeze, these one or two points are most likely wrong"* — and a flag may point at an uncovered scenario or the contract shape, not only a spec assumption. The ranking you do here in Specify is the first input into that one gate. See [05 Contract](./05-step-3-contract.md) and the `add` skill's `run.md`. + +--- + +## When the feature has a user interface + +For anything with a UI, extend this step with a quick design: the **user flows** (the happy path and the main alternatives) and **every screen state** — loading, empty, error, and success. Correct logic behind a confusing or incomplete interface is still a poor product, and undesigned states are exactly where an AI will improvise something ugly. In the early **Prototype** stage, this design work is the main event and the code is throwaway (see [10 Stages](./10-setup-and-stages.md)). diff --git a/.add/docs/04-step-2-scenarios.md b/.add/docs/04-step-2-scenarios.md new file mode 100644 index 000000000..6e7c08a62 --- /dev/null +++ b/.add/docs/04-step-2-scenarios.md @@ -0,0 +1,80 @@ +# 04 · Step 2 — Scenarios + +[← 03 Step 1 Specify](./03-step-1-specify.md) · [Contents](./README.md) · Next: [05 Step 3 Contract →](./05-step-3-contract.md) + +> **Purpose:** rewrite each rule from the spec as a concrete, pass-or-fail scenario. +> **Produces:** `features/.feature`. +> **Person's job:** decide what "correct" looks like in concrete situations. **AI's job:** draft the scenarios. + +> **Part of the specification bundle (v7).** In the default flow these scenarios are drafted by the AI alongside the spec, contract, and failing tests as **one bundle**, approved by a person **once** (the one approval), at the contract freeze — not signed off step by step. This chapter is how to get the scenarios *right*; [05 Contract](./05-step-3-contract.md) is where the bundle is frozen. See [11 Governance](./11-governance.md). + +--- + +## Why turn rules into scenarios + +A plain rule is still open to interpretation. "Source must have enough balance" leaves open: enough for what, exactly? What happens to the balances when it is *not* enough? A scenario removes the interpretation by pinning a specific situation to a specific expected result. + +Scenarios occupy a unique position: they are **readable by people and checkable by machines at the same time.** A product owner can confirm a scenario is what they meant; a test can be generated directly from it. This makes them the bridge between the human-led half of the flow and the machine-led back. They are the single most leverage-bearing artifact in the method, because everything downstream — the tests, and through them the build's definition of success — is generated from them. + +## The form + +Each scenario has three parts: + +- **Given** — the starting situation. +- **When** — the action taken. +- **Then** — the result that must follow. + +Where a rule also constrains what must *not* change, add an **And** clause to state it. Unwanted side effects are caught by what you assert stays the same, not only by what you assert changes. + +## Template + +``` +Scenario: + Given + When + Then + And # when relevant +``` + +## ▶ Example + +``` +Scenario: successful transfer + Given A has 100 and B has 0, both mine + When I transfer 30 from A to B + Then A has 70 and B has 30 + +Scenario: insufficient funds + Given A has 20, mine + When I transfer 50 from A to B + Then it is rejected "insufficient_funds" + And no balance changes + +Scenario: not my account + Given account C is not mine + When I transfer 10 from C to B + Then it is rejected "forbidden" +``` + +The `And no balance changes` line is doing real work: it specifies that a rejected transfer must leave the world untouched — a property the AI could easily violate by deducting before checking. + +## The AI's role here + +Hand the AI the spec and have it draft a scenario for each rule, including the rejection rules. Then read them as the person who owns the requirement: do they describe what you actually meant? Correct any that drift. See `playbook/2_scenarios.md` in [Appendix B](./appendix-b-prompts.md). + +## Common mistakes + +- **Only happy-path scenarios.** Every "Reject" rule in the spec needs its own scenario, or that rule will never be verified. +- **Vague results.** "Then it works" is not checkable. The result must be a specific, observable fact ("A has 70"). +- **Forgetting the unchanged state.** For any rejection, assert that nothing changed; otherwise a partial, corrupting failure can pass. + +## Exit check + +- [ ] Every "Must" rule has at least one scenario. +- [ ] Every "Reject" rule has at least one scenario. +- [ ] Each scenario's result is a specific, observable fact. +- [ ] Rejections assert what must stay unchanged. + +## If the check fails + +A rule with no scenario will never be tested, and therefore will never be verified — it is a rule in name only. Either write the missing scenario or remove the rule from the spec. Do not carry an unscenarioed rule into the contract. diff --git a/.add/docs/05-step-3-contract.md b/.add/docs/05-step-3-contract.md new file mode 100644 index 000000000..f81bafb4c --- /dev/null +++ b/.add/docs/05-step-3-contract.md @@ -0,0 +1,80 @@ +# 05 · Step 3 — Contract + +[← 04 Step 2 Scenarios](./04-step-2-scenarios.md) · [Contents](./README.md) · Next: [06 Step 4 Tests →](./06-step-4-tests.md) + +> **Purpose:** fix the external shape of the feature — interfaces, data structures, names, and error cases — and freeze it. +> **Produces:** `contracts/.md` (plus a mock and contract tests). +> **Person's job:** approve and freeze the shape. **AI's job:** generate the first draft, the mock, and the contract tests. + +> **The one approval lands here (v7).** In the default flow the AI drafts spec, scenarios, this contract, and the failing tests as **one specification bundle**, and a person gives a **single approval at this freeze**. Freezing the contract is the one human gate of the bundle, not the third of three sign-offs; reject any part and the whole bundle returns to draft (backward correction, not failure). See [11 Governance](./11-governance.md). + +--- + +## The decision point of the whole method + +This step is the decision point between the human-led and machine-led halves of the flow, and it is what makes everything after it safe. + +The reasoning is simple. The AI is allowed to write and rewrite code quickly. That is only safe if there is a stable surface that the rest of the system depends on and that the AI is not allowed to disturb. The frozen contract is that surface. Below it, the code is disposable and can be regenerated freely; above it, nothing breaks, because the shape it depends on does not move. + +Freezing the contract is therefore not bureaucracy — it is the precondition for granting the AI real autonomy in the build step. Without it, every regeneration risks silently changing an interface that another part of the system relies on. + +## What the contract contains + +- **Interfaces** — the endpoints, functions, or messages, with their inputs and outputs. +- **Data structures** — the request and response shapes, and the persistent schema. +- **Names** — drawn from the project glossary, so the same concept has the same name everywhere. +- **Error cases** — the defined failures, using the error codes from the spec. + +## Template + +``` +# contracts/.md + body: { } + 200 -> { } + 4xx -> { error: "" | "" } +Schema: +Status: FROZEN @ v +``` + +## ▶ Example + +``` +POST /transfers body: { fromAccountId, toAccountId, amount } + 200 -> { transferId, fromBalance, toBalance } + 400 -> { error: "amount_invalid" | "same_account" | "insufficient_funds" } + 403 -> { error: "forbidden" } +Schema: accounts.balance (read + write, must be transactional) +Status: FROZEN @ v1 +``` + +Every error code traces back to a rejection rule in the spec, and the schema note (`must be transactional`) flags the one place where correctness depends on more than shape — a hint the verification step will follow up. + +## The AI's role here + +The AI generates the contract from the spec and design, and additionally produces two things that make the contract enforceable: a **mock server** that returns the contracted shapes, and **contract tests** that pin those shapes. With the mock in place, work that depends on this feature can proceed before the real code exists. See `playbook/3_contract.md` in [Appendix B](./appendix-b-prompts.md). + +## The change-request rule + +Once frozen, a contract does not change casually. A needed change is a **change request**: you return to [Step 1](./03-step-1-specify.md), adjust the spec, re-freeze at a new version, and come forward again. The AI never alters a frozen contract on its own initiative. + +This rule is what keeps the contract trustworthy as a foundation. If it could drift, nothing built on it would be safe. + +> **Do:** version and freeze the contract before any implementation. +> **Don't:** let the build step quietly change an interface to make code easier — that breaks everything depending on it. + +## Common mistakes + +- **Inconsistent names.** If the contract calls it `fromAccountId` and the schema calls it `src_acct`, the AI will produce subtle mismatches. Use the glossary everywhere. +- **Undefined errors.** Every failure the spec rejects must have a contracted response, or callers cannot handle it. +- **Freezing too early or too late.** Freeze once the spec and design are stable — not before they are agreed, and not after code has already been written against an unfrozen shape. + +## Exit check + +- [ ] Contract is versioned and marked `FROZEN`. +- [ ] Contract tests pass against the mock. +- [ ] Every name matches the project glossary. +- [ ] Every spec rejection has a contracted error response. + +## If the check fails + +If the contract is not yet stable enough to freeze, the upstream artifacts are not settled — return to the spec or scenarios and resolve what is still open. If a frozen contract later needs to change, treat it as a change request rather than an edit; the discipline is the point. diff --git a/.add/docs/06-step-4-tests.md b/.add/docs/06-step-4-tests.md new file mode 100644 index 000000000..a53ef9ab0 --- /dev/null +++ b/.add/docs/06-step-4-tests.md @@ -0,0 +1,73 @@ +# 06 · Step 4 — Tests + +[← 05 Step 3 Contract](./05-step-3-contract.md) · [Contents](./README.md) · Next: [07 Step 5 Build →](./07-step-5-build.md) + +> **Purpose:** turn the scenarios and contract into automated tests, and confirm they fail before any code exists. +> **Produces:** a failing (red) automated test suite. +> **Person's job:** set the targets and coverage. **AI's job:** generate the tests. + +> **Part of the specification bundle (v7).** In the default flow these tests are drafted by the AI as part of the specification **bundle** (spec · scenarios · contract · tests) and approved by a person **once**, at the contract freeze — the tests are part of what that one approval covers. They still must be **red before the build**. See [11 Governance](./11-governance.md). + +--- + +## Why tests come before code + +This is the step that operationalizes the second principle — *trust through evidence, not inspection.* The tests written here are how you will judge the AI's code in [Step 5](./07-step-5-build.md). For that judgment to be honest, the tests must exist *before* the code. + +The reason is mechanical. If code is written first and tests after, the tests are unconsciously shaped to match whatever the code happens to do — including its mistakes. Tests written first, from the scenarios, are shaped only by the agreed definition of correct. They are an independent standard the code must rise to meet, not a description of what the code already does. + +## The must-fail principle + +After generating the tests, you run them — and they must **fail**, because no implementation exists yet. This sounds trivial and is not. A test that passes before any code is written is testing nothing; it is a false reassurance that will later wave bad code through. Confirming the suite is "red for the right reason" (a missing implementation, not a broken test) is what makes it genuinely protective. + +## What to test + +- **One test per scenario** — every scenario from [Step 2](./04-step-2-scenarios.md) becomes an executable test. +- **Contract conformance** — tests that pin the shapes and error responses from [Step 3](./05-step-3-contract.md). +- **Edge cases from the spec** — the boundary values implied by the "Reject" rules. +- **Behavior, not internals** — tests assert what the feature does (the observable result), never how it is implemented, so the code can be regenerated freely beneath them. + +## ▶ Example + +```python +def test_successful_transfer(): + a = account(balance=100, owner=me); b = account(balance=0, owner=me) + r = transfer(a.id, b.id, 30) + assert r.status == 200 + assert a.balance == 70 and b.balance == 30 + +def test_insufficient_funds(): + a = account(balance=20, owner=me); b = account(balance=0, owner=me) + r = transfer(a.id, b.id, 50) + assert r.status == 400 and r.error == "insufficient_funds" + assert a.balance == 20 # unchanged — the side-effect assertion + +def test_not_my_account(): + c = account(balance=100, owner=someone_else); b = account(balance=0, owner=me) + r = transfer(c.id, b.id, 10) + assert r.status == 403 and r.error == "forbidden" +``` + +Run this now, with no implementation: all three fail. That is the correct, honest starting point for the build. + +## The AI's role here + +The AI generates the test suite from the scenarios and contract. Your job is to confirm two things it cannot judge for itself: that each test asserts *behavior* rather than internal detail, and that none of them pass by accident before code exists. See `playbook/4_tests.md` in [Appendix B](./appendix-b-prompts.md). + +## Common mistakes + +- **Tests that test the implementation.** Asserting on private internals couples the test to one version of the code and defeats disposability. +- **A green suite before the build.** Means the tests are not actually exercising the missing feature — fix them now. +- **Skipping the side-effect assertions.** Without `assert a.balance == 20` on the rejection path, a corrupting partial failure passes silently. +- **No coverage target.** Without a recorded target, coverage can quietly erode during the build. + +## Exit check + +- [ ] One test exists per scenario. +- [ ] The suite runs in the pipeline and is **red for the right reason**. +- [ ] Tests assert observable behavior, not internals. +- [ ] A coverage target is recorded. + +## If the check fails + +If a test passes before any implementation, it is a fake test — repair it before continuing, because it is your only independent check on the AI. If the suite is red for the wrong reason (a syntax or harness error), fix the harness first; a build cannot be judged against a broken net. diff --git a/.add/docs/07-step-5-build.md b/.add/docs/07-step-5-build.md new file mode 100644 index 000000000..32e0e4c02 --- /dev/null +++ b/.add/docs/07-step-5-build.md @@ -0,0 +1,80 @@ +# 07 · Step 5 — Build + +[← 06 Step 4 Tests](./06-step-4-tests.md) · [Contents](./README.md) · Next: [08 Step 6 Verify →](./08-step-6-verify.md) + +> **Purpose:** have the AI implement the feature so that every failing test passes. +> **Produces:** working code, plus the evidence that the tests now pass. +> **Person's job:** direct, in small batches. **AI's job:** implement. + +--- + +## The only step the AI leads + +This is the step the AI is genuinely good at, and the only one where it should be doing the heavy lifting. It works precisely because the previous four steps removed all the ambiguity: the AI is no longer guessing what to build: it has a spec, a set of scenarios, a frozen contract, and a suite of failing tests that define "done" exactly. Its task is narrow and checkable — turn the suite green. + +This is the difference between AIDD and vague-prompt coding. The same agent that produces confident nonsense from "build me a transfer feature" produces correct, bounded code from "make these specific failing tests pass without changing them." The agent did not change; the direction did. + +## The build prompt + +The instruction is explicit about constraints, because the constraints are what keep the speed safe. + +``` +Read SPEC.md, contracts/.md, and tests/_test.py. +Implement the feature so that EVERY test passes. +Constraints: + - Do NOT change any test. + - Do NOT change the contract. + - . + - Stop and ask if any requirement is unclear — do not guess. + - Use only packages listed in dependencies.allowlist. +Report which tests pass and exactly what you changed. +``` + +For the running example, the feature-specific safety rule is *"make the balance update atomic — debit and credit occur in a single transaction."* This is the one correctness property the tests alone may not force, so it is named directly to the builder. + +See `playbook/5_build.md` in [Appendix B](./appendix-b-prompts.md). + +## Work in small batches + +Direct the AI one task at a time, and keep each task small enough that its result can be reviewed in full. This is a direct application of the principle *you cannot move faster than you can verify.* A single enormous change that turns the whole suite green at once is not a triumph — it is an unreviewable blob. Small batches keep the verification step (next chapter) tractable and keep a human genuinely in the loop. + +## The iteration loop + +``` +AI writes code → pipeline runs the tests → some still fail + → AI iterates → ... → all green → hand to Verify +``` + +The loop is tight and largely autonomous within a task: the AI runs the tests, sees what fails, and adjusts. Your attention is needed at the boundaries — defining the task going in, and reviewing the result coming out — not on each internal iteration. + +## The cardinal rule: never change the test to pass + +An AI under pressure to make a suite green has an available shortcut: weaken or delete the failing test. This must be forbidden explicitly and caught reliably. A test changed to fit the code inverts the entire method — the code is now judging itself. If you find a test was altered during the build, reject the change outright and re-prompt with the constraint restated. + +The same applies to the contract: the build implements *against* the frozen contract and may not edit it. A genuine need to change either is a change request that returns to an earlier step. + +## How much autonomy + +The autonomy granted in this step should match the evidence and your review capacity (see [11 Governance](./11-governance.md)): + +- Where the area is new or risky, the AI proposes and a person reviews every change. +- Where the contract and tests are solid, the AI generates freely and a person reviews each batch. +- Only in narrow, well-tested areas, with a full evidence bundle attached, may the AI integrate its own work. + +## Common mistakes + +- **Batches too large to review.** Shrinks verification to approving without reading. +- **Letting the AI add unknown dependencies.** The allow-list check in the pipeline should block this automatically; if it does not, the supply-chain risk is real (an AI may invent a plausible package name that an attacker has registered). +- **Accepting "all tests pass" without reading the change.** Passing tests are necessary, not sufficient — the next step exists for exactly this reason. + +## Exit check + +- [ ] All tests pass. +- [ ] Test coverage did not decrease. +- [ ] No test and no contract was modified by the AI. +- [ ] No dependency outside the allow-list was added. +- [ ] The change is small enough to review in full. + +## If the check fails + +If the AI weakened a test, reject and re-prompt. If it added an out-of-allow-list package, the pipeline blocks it; have the AI find an approved alternative or raise the package for human approval. If the batch is too large to review, ask the AI to split the work and resubmit. Only once the exit check passes does the change proceed to verification. diff --git a/.add/docs/08-step-6-verify.md b/.add/docs/08-step-6-verify.md new file mode 100644 index 000000000..6f6c6bf55 --- /dev/null +++ b/.add/docs/08-step-6-verify.md @@ -0,0 +1,81 @@ +# 08 · Step 6 — Verify + +[← 07 Step 5 Build](./07-step-5-build.md) · [Contents](./README.md) · Next: [09 The loop →](./09-the-loop.md) + +> **Purpose:** confirm the result is correct and safe to release. +> **Produces:** a reviewed change with a recorded outcome, ready to release. +> **Who resolves it:** set per task by the `autonomy:` header. Under `autonomy: auto` (the default) the run resolves the gate on evidence; under `conservative`, or for any residue, it is the human's check. **Security always escalates to a human.** + +--- + +## Where trust is actually established + +The build produced passing tests. That is necessary but not sufficient. Verification is where a person establishes trust — and the principle governing it is *trust through evidence, not inspection.* + +This needs care, because it is easy to misread. "Not by inspection" does not mean "do not look at the code." It means the *basis* of trust is the passing evidence plus a deliberate check of the specific things tests cannot easily catch — not a general impression that the code reads plausibly. Plausibility is exactly the trap: AI code is frequently plausible and wrong. So verification has two parts: confirm the evidence, then check the known non-functional risks. + +## Who resolves Verify — the automated quality gate + +Verify can be resolved two ways, set per task by the `autonomy:` header (see [governance](./11-governance.md) and the autonomy level): + +- **Auto (the default).** When `autonomy: auto`, the run resolves the gate on **evidence** rather than waiting for a person — but only when *all* of these hold: every test green, coverage not decreased, no test weakened and no contract edited, the convergence loops dry, and **no residue** (security, concurrency, or architecture). It records `PASS` as *auto-resolved*, naming the run as the accountable owner — an explicit pass, not a skip. This is principle 7: a gate may be resolved by evidence when that evidence is sufficient and the result is logged. +- **Human.** When `autonomy: conservative`, or whenever the run finds residue it cannot judge, the gate stops for a person; the two parts below are theirs. + +**Security is always a `HARD-STOP` and is never auto-passed, at any autonomy level.** The two parts that follow — confirm the evidence, then check the non-functional risks — are what *either* resolver works through; the only question is whether a person or the recorded run signs the outcome. + +## Part one — confirm the evidence + +- [ ] All tests pass. +- [ ] Coverage did not decrease. +- [ ] No test or contract was altered during the build. + +If any of these is false, stop here and return to the build; there is nothing to verify yet. + +## Part two — check what tests miss + +Automated tests are excellent at behavior on defined inputs and poor at a few specific things. Check those by hand, every time: + +- **Concurrency and timing.** Is the operation correct when two of them happen at once? Tests usually run serially and miss races. + - ▶ *Example: the balance update must be one atomic transaction. Confirm that two simultaneous transfers from the same account cannot both pass the balance check and overdraw it.* This is the single most important check for this feature, and it is the reason the build prompt named atomicity explicitly. +- **Security.** Are there exposed secrets, injection openings, or unexpected dependencies? AI-generated code is known to hardcode secrets and to pull in packages by plausible-but-wrong names. +- **Architecture conformance.** Does the change respect the layering and dependency rules in `CONVENTIONS.md`? Speed with no architectural check produces a fast-growing tangle that becomes unmaintainable within months. + +## Part three — the deep check (do not skim) + +Two failures slip straight past green tests. The first is code that is never *wired in* — a new function that nothing calls, an endpoint no route reaches: the tests for it pass in isolation while the feature is, in practice, absent. The second is the opposite — code left *dead* behind a path nothing exercises, quietly rotting. And for a change that produced prose rather than code, the equivalent failure is signing off on a claim you never actually read in full. Plausibility hides all three. So verification carries one explicit requirement beyond the non-functional review: + +> Deep check — do not skim. If the task produced code, record that every new symbol is referenced (wiring) and that no new dead/unused code was introduced. If it produced prose or non-code, record a semantic read — what you read in full and what it confirmed. Which path applies is the resolver's judgement; the engine never classifies. + +This is *evidence*, not impression: a reference search showing where each new symbol is called, a scan confirming nothing new is orphaned, or — for prose — a note of exactly what was read and what it confirmed. An unfilled deep check is a **shallow verify**, not a pass. The engine cannot judge wiring, dead code, or whether prose was truly read; the resolver records the evidence, and a person (under `conservative`) or the recorded run (under `auto`) signs it. + +## Recording the outcome + +Every verification ends with exactly one recorded outcome, with an accountable owner — never a silent pass: + +| Outcome | Meaning | Allowed when | +|---------|---------|--------------| +| `PASS` | all checks met | the normal path | +| `RISK-ACCEPTED` | proceed with a signed waiver: named owner, linked ticket, expiry date | a non-security gap only | +| `HARD-STOP` | cannot proceed | any failing test or any security finding | + +A security finding is always a `HARD-STOP`; it is never waved through with a waiver. A `RISK-ACCEPTED` outcome is a deliberate, documented decision to ship a known, non-security limitation — not a way to skip the check. + +## The verification checklist + +- [ ] All tests pass (the evidence). +- [ ] Concurrency/timing of the risky operation is safe. +- [ ] No exposed secrets, injection openings, or unexpected dependencies. +- [ ] Layering and dependencies follow `CONVENTIONS.md`. +- [ ] Deep check (do not skim): for code, every new symbol is referenced (wiring) and no new dead/unused code was introduced; for prose/non-code, a semantic read is recorded. +- [ ] The change is approved — by a person, **or** (under `autonomy: auto`, no residue) auto-resolved by the run as the recorded accountable owner. +- [ ] An outcome is recorded (`PASS` / `RISK-ACCEPTED` / `HARD-STOP`). + +## Common mistakes + +- **Shipping on plausibility.** Reading the diff, finding it reasonable, and approving — without the evidence and the non-functional review — is the precise failure the method exists to prevent. +- **Treating a security gap as acceptable risk.** It is a `HARD-STOP`, not a waiver. +- **Skipping the concurrency check** because the tests are green. Tests rarely exercise simultaneity; this is a manual check by design. + +## If the check fails + +A failing test or a security finding returns the change to the build step ([Step 5](./07-step-5-build.md)). A non-security limitation may proceed only with a signed `RISK-ACCEPTED` record carrying an owner and an expiry — so the team can find and close it later. Nothing proceeds on an unrecorded decision. diff --git a/.add/docs/09-the-loop.md b/.add/docs/09-the-loop.md new file mode 100644 index 000000000..1f471cfd3 --- /dev/null +++ b/.add/docs/09-the-loop.md @@ -0,0 +1,67 @@ +# 09 · The loop — observe and learn + +[← 08 Step 6 Verify](./08-step-6-verify.md) · [Contents](./README.md) · Next: [10 Setup and stages →](./10-setup-and-stages.md) + +> **Purpose:** release the verified change, watch how it behaves in reality, and turn what you learn into the next specification. +> **Produces:** a running feature, observations, and the next `SPEC.md` delta. + +--- + +## The flow is a loop, not a line + +Older mental models end at "ship." That framing is the source of a common pathology: teams treat release as a finish line, and so they hide defects to protect the line rather than manage them in the open. In AIDD, release is not the end of the flow — it is the point where the most reliable information about the feature finally becomes available: how it behaves with real users, real data, and real load. + +That information is the input to the next cycle. What you learn in production becomes the next specification, and the flow returns to [Step 1](./03-step-1-specify.md). The cycle is continuous. + +## Release deliberately + +Release behind a mechanism that limits the scope of impact of a mistake — a feature flag, a gradual rollout, or both. The verification step established that the feature is correct against everything you anticipated; a controlled release is your protection against what you did not anticipate. If something is wrong, you want to affect a few users and roll back, not affect everyone and scramble. + +## Reuse the scenarios as monitors + +The scenarios from [Step 2](./04-step-2-scenarios.md) have a second life here. They described the behavior you expected; in production they become the behavior you monitor. The same definition of "correct" that drove the tests now drives the alerts. + +**What to watch (▶ example):** + +- the overall transfer error rate; +- the rate of each individual rejection (`amount_invalid`, `same_account`, `insufficient_funds`, `forbidden`) — a sudden spike in one is a signal, not noise; +- latency, especially of the atomic balance update under load. + +## Turn observation into the next spec + +Every defect, surprise, or new need is written up as a change to the specification — a delta that re-enters the flow at [Step 1](./03-step-1-specify.md). An error rate that is too high, a rejection that fires more than expected, a user behavior nobody designed for: each becomes a concrete, specified next step rather than a vague intention. + +This is also where the AI returns to a useful role: summarizing telemetry, clustering errors into themes, and drafting the proposed spec delta for a person to review. But the production decisions — what to roll back, what to prioritize — remain human. + +## Lessons learned and the retrospective consolidation + +A spec delta feeds the *next feature*. But a loop also teaches the **method itself** — that the domain model missed a boundary, that a whole class of scenario was never tested, that a build convention helped or hurt. AIDD captures those as **lessons learned**: a single tagged learning, written in the Observe step, marking which of the five competencies it sharpens. + +| tag | competency | a delta here means you learned something about… | +|-----|------------|--------------------------------------------------| +| `DDD` | Domain | the domain model — an entity, rule, or boundary the spec assumed wrong | +| `SDD` | Spec | what the feature must do or reject — a missing or wrong requirement | +| `UDD` | UI/UX | the user-facing shape — a flow, affordance, or wording that misled | +| `TDD` | Test | how we prove correctness — a missing scenario, a flaky or hollow test | +| `ADD` | AI/build | how the AI builds — a harness, prompt, or convention that helped or hurt | + +Each delta is one tagged entry — `- [COMPETENCY · status] the learning (evidence: a pointer)` — and the evidence is **required**: a failing scenario, a production signal, a review note. No evidence means it is an opinion, not a delta. The AI **emits** deltas as `open`; it never consolidates its own. Consolidation is judgment, and judgment is the human's — the same verify/observe decision point that keeps the AI from grading its own work. + +**The consolidation.** At milestone close (or on demand, when open deltas pile up), a person runs the retrospective consolidation: **gather** every `open` delta across the milestone's tasks, **group** them by competency, **propose** the exact foundation edit for each, **confirm** with the human one by one, then **write** — append-only — flipping each delta to `folded` (merged) or `rejected` (considered and deliberately not merged, left in place so the trail survives), and bumping the `foundation-version:` marker. `DDD`/`SDD`/`UDD` deltas consolidate into the matching section of `PROJECT.md`; `TDD`/`ADD` consolidate into `CONVENTIONS.md` (they sharpen the engine, not the product); and **every** consolidation also appends one row to `PROJECT.md` §Key Decisions — the universal, auditable record of what the foundation learned. + +**Tooling.** `add.py deltas` lists every open delta across the project (so nothing waiting to be consolidated is invisible); `add.py check` lints each delta's well-formedness — known competency tag, valid status, non-empty evidence. There is deliberately **no `add.py fold`**: the engine stays judgment-free, and the ritual lives with the human who owns it. + +## Re-entrancy: the loop is the whole point + +Two principles converge here. *The flow is re-entrant* — any step can send you back to an earlier one — and *the flow is a loop* — production feeds the next specification. Together they mean the artifacts you built are never "finished"; they are living documents that the next cycle refines. + +A team operating this way does not experience requirements changing as a failure of planning. It experiences it as the system working: reality is teaching the specification, and the specification is teaching the next build. + +## The milestone holds until its goal is met + +A single feature loops through Observe back to Specify; a **milestone** has the same shape at a larger scale, and a gate to match. A milestone is not finished when its tasks are done — it is finished when its **goal** is met, expressed as the exit criteria in `MILESTONE.md`. So `add.py milestone-done` is **goal-gated**: it refuses to close a milestone while any exit criterion is still unchecked, and **holds until** every box is checked. Those checkboxes are the human's affirmation that the goal is genuinely met — the engine reads the tally, it never judges the goal itself. (A milestone with no exit criteria closes as before; `milestone-done` is the only path to `done`, and archiving refuses anything not yet done — so the one gate cannot be slipped.) + +While the milestone is held open, the work each task leaves behind — open lessons, and items discovered but out of scope — becomes its next tasks: the AI proposes them, the human confirms, and the loop continues until the goal is reached. The milestone is the loop made concrete; the exit criteria are its finish line. + +> **Do:** release small, watch the scenarios, and feed every learning back into the spec. +> **Don't:** treat shipping as the end. The most valuable information about a feature arrives *after* it ships. diff --git a/.add/docs/10-setup-and-stages.md b/.add/docs/10-setup-and-stages.md new file mode 100644 index 000000000..4d55861d1 --- /dev/null +++ b/.add/docs/10-setup-and-stages.md @@ -0,0 +1,118 @@ +# 10 · Project setup and stages + +[← 09 The loop](./09-the-loop.md) · [Contents](./README.md) · Next: [11 Governance →](./11-governance.md) + +This chapter covers two operational matters: what you set up once per project, and how the same flow runs at different depths as a product matures. + +--- + +## Setup: the AI drafts, you approve the baseline + +Before the first feature, the project needs a foundation — but standing it up is no longer your chore. Point ADD at the repo and **the AI does the drafting**: it runs `init` itself, reads what is there, and fills the foundation the whole project depends on. Your single act is the **baseline approval** — the one human gate that freezes it. + +**What the AI drafts.** From an existing codebase it works **silently** — the code answers the questions a setup interview would ask. On an empty repo it runs a short **four-lens interview** (domain · spec · users · decisions), then drafts. Either way it fills the living documentation — the files that outlive all code — and drafts the first milestone's scope and the first task's candidate contract: + +| Item | File | Purpose | +|------|------|---------| +| Foundation | `PROJECT.md` | domain · active spec · UI/UX · key decisions — the context every task reads first | +| Conventions | `CONVENTIONS.md` | naming, layout, language, formatter — living documentation | +| Model record | `MODEL_REGISTRY.md` | which AI model and version the project uses, for reproducibility and audit | +| Dependency allow-list | `dependencies.allowlist` | the packages the AI may use; the pipeline rejects others | +| Prompt playbook | `playbook/` | the six prompts from [Appendix B](./appendix-b-prompts.md) | +| Repository + pipeline | — | runs the gates on every change | + +Every drafted decision is tagged **evidence-grounded** (read from the code) or **guessed** (thin or inferred) and listed lowest-confidence-first in a `SETUP-REVIEW.md`, so the one signature you give is informed rather than given without reading. + +**The baseline approval.** The AI presents `SETUP-REVIEW.md`; you check the `guessed` rows; you **lock** — once. That single act freezes the foundation, the first scope, and the first contract together. It is the setup-level analog of the [contract freeze](./05-step-3-contract.md), and it doubles as the first task's contract approval — so there is no separate sign-off. Before the lock the engine lets the AI draft but refuses to cross into build; after it, the build opens. + +**Setup exit check** + +- [ ] Foundation + living docs drafted (brownfield: from the code, evidence-tagged; greenfield: from the interview, gaps flagged `guessed`). +- [ ] `SETUP-REVIEW.md` lists every drafted decision lowest-confidence-first. +- [ ] The model is pinned; the allow-list exists and the pipeline fails on any package outside it. +- [ ] The pipeline runs and is green on the empty skeleton. +- [ ] The human **locked down** — and only then did the first feature's build open. + +Do not start a feature until the pipeline is green and the foundation is locked. The baseline approval turns the AI's draft into committed direction; the pipeline enforces every later exit check without anyone having to remember to. + +--- + +## Stages: the same flow at increasing depth + +A *stage* is one pass through the flow at a chosen depth. The steps never change between stages; what changes is how deeply you run each one. The instinct to skip steps for an early prototype is right in spirit but wrong in form — you do not skip steps, you run them lightly. + +### The depth matrix + +Depth: **Deep** (full rigor) · **Core** (real but scoped) · **Light** (just enough) · **—** (skipped or stubbed). + +| Step | Prototype | Proof of Concept | MVP | Production-Ready | +|------|:---------:|:----------------:|:---:|:----------------:| +| 1 Specify | Light | Deep (risky slice) | Deep | Deep | +| (design, if UI) | **Deep** | Light | Core | Deep | +| 2 Scenarios | Light | Core | Deep | Deep | +| 3 Contract | — | Core | Deep | Deep | +| 4 Tests | — | Core | Core | Deep | +| 5 Build | Light (throwaway) | Core | Core | Deep | +| 6 Verify | Light | Core | Core | Deep | +| Loop / operate | — | — | Light | Deep | +| **Typical time\*** | ~2–5 days | ~1–3 weeks | ~4–8 weeks | ~4–8+ weeks | +| **Code is** | disposable | disposable | kept | hardened | + +\* *Ranges assume a small team on a single product slice. Scale by scope and by the number of parallel streams. The pace is set by judgment and review capacity, not by how fast the AI can type — adding more AI does not compress the human-led steps.* + +### Stage by stage + +**Prototype — prove the experience.** Run the design deeply and everything else lightly; the code is throwaway. The achievement is that a stakeholder reacts to something tangible and a go/no-go on the concept becomes possible. Do not expect real data, tests, or anything that survives. + +**Proof of Concept — retire the biggest technical risk.** Run the contract, tests, and build *deeply but only on the single riskiest slice*. The achievement is evidence that the hardest unknown is solvable, which turns an MVP estimate from hopeful into credible. Do not expect breadth or polish. + +**MVP — deliver value to real users.** Run the full flow at a narrow scope — the first complete loop, including light observation. The achievement is real users getting value while you learn from them. Do not expect scale or full operational rigor. + +**Production-Ready — run safely at scale.** Run every step at full rigor and deepen the operate-and-learn loop: service objectives, incident response, tested rollback, gradual delivery. The achievement is a system that is tested, secure, observable, and supportable. Do not expect "zero defects"; expect managed risk with a working feedback loop. + +### What carries forward + +The durable thing is never the code: + +| Transition | Discard | Keep | +|------------|---------|------| +| Prototype → POC | the prototype code | the validated experience (design, flows) | +| POC → MVP | the spike code | the validated approach + the risky-interface contract | +| MVP → Production | nothing | everything; the code is real and is hardened | + +The living documentation thickens as you move right: a prototype leaves you a validated design; a proof of concept adds a proven approach and a contract; the MVP adds real, kept code. By production, you are hardening, not rebuilding. + +### Graduating between stages + +Moving up a stage — most consequentially MVP → Production — is its own scope level, the fourth after setup, intake, and the milestone loop. It is *not* a label someone types: a project earns production through a human-confirmed roadmap of the hardening work, never through a bare flip. The `add` skill drives this in `graduate.md`; the shape is five steps. + +**The cue.** When every milestone is `done` *and* the human's **stage-goal-criteria** in `PROJECT.md` are all `[x]`, `add.py status` prints `→ MVP covered → propose graduation`. Until both tallies complete, nothing here applies — a project with no stage-goal-criteria block behaves exactly as before. + +1. **Gather the analytics.** `add.py graduation-report` clusters the whole MVP loop's evidence into five labeled record-sets — open deltas by competency, open RISK-ACCEPTED waivers by expiry, RETRO records, verify residue, and observe-loop coverage gaps. It *gathers, never judges*: there is no readiness verdict, only the records you reason from. +2. **Interview.** Synthesize *what production means here* with the human, using those records as the agenda. This synthesis is the judgment the engine refuses to make. +3. **Draft the roadmap.** For each production outcome the interview surfaces, draft a production milestone with the existing command — `add.py new-milestone --stage production --goal "…"` — and write its exit criteria. The roadmap is **≥1** milestone; the hardening work itself is what those milestones contain. +4. **Human confirms.** The human accepts, edits, or declines each draft. Nothing is created on an unconfirmed draft. +5. **Flip — the final step.** Only now run `add.py stage production`. + +**The floor the engine enforces.** `add.py stage production` is guarded: it refuses with `stage_no_roadmap` (non-zero exit, state byte-unchanged) when no milestone has `stage: production`. The check is a *tally* — does a production-roadmap record exist? — never a readiness judgment, mirroring the milestone goal-gate. `--force` overrides it for grandfathered or edge cases; use it deliberately, not as the normal path. The guard is on the `→production` transition only; flips to prototype/poc/mvp are unchanged. The engine never advances the stage on its own — it gathers, counts, and holds the floor while the human judges and confirms. + +--- + +## Parallel streams (opt-in) + +The default is one task at a time. But when a milestone holds several tasks whose dependencies are already `PASS` and a reviewer is ready, you may run them **concurrently** — one worker per ready task, each building behind its own frozen contract. + +**Be honest about the gain.** With one human reviewer you cannot beat `review_time × N_tasks`; the human-led decision points are serial. So the win is **not throughput** — it is that the reviewer is *never blocked waiting on a build*. While a person reviews task A's specification bundle, the builds for B, C, and D run behind *their* frozen contracts. You hide build latency under human latency; do not promise more. + +**Two queues, no new state** — both read from `add.py status`: + +- **READY-QUEUE** — tasks in the active milestone where the phase is not `done` and every dependency already reads `gate=PASS`. These are the only tasks a worker may pick up; a task finishing `PASS` unblocks its dependents on the next `status`. +- **REVIEW-QUEUE** — the irreducibly serial part: the **bundle approval** (contract freeze) and any **Verify escalation**. One human, one queue, presented one at a time — never a batch that invites approval without reading. + +**The autonomy level is the throttle.** At `conservative`, both gates queue on the human (pure pipelining — builds overlap, nothing auto-resolves). At `auto` (the default), only the bundle-approval decision point and residue escalations queue; Verify auto-PASSes on evidence, so real concurrency follows. The floor never drops below **one human approval per task, at the contract decision point**. + +**Design for failure (required).** Lease each task to its worker with a timeout — if a worker dies, release the claim back to READY rather than trusting partial work. A worker that hits a stop-and-escalate blocks only its own task; siblings keep running. And if several workers fail in one wave, trip a circuit-breaker and fall back to sequential — repeated failure means the scope was wrong, not the parallelism. + +**The hard boundary.** The orchestrator owns every shared write — `state.json`, `MILESTONE.md`, and each `add.py advance`/`gate` call (always with the explicit task slug). A worker owns only its own task directory and is isolated in a git worktree, so concurrent builds cannot collide. Merge is **serial**: bring worktrees back one at a time and run an **integration Verify** for the concurrency and architecture conflicts that two-green-in-isolation tasks can still produce — automation never auto-passes that step. + +The full, agent-agnostic worker contract (the prompt a worker runs) and the per-runner spawn adapter live in the skill's `streams.md`; this section is the *why* and the safety frame, not the operational recipe. diff --git a/.add/docs/11-governance.md b/.add/docs/11-governance.md new file mode 100644 index 000000000..4cffe3e54 --- /dev/null +++ b/.add/docs/11-governance.md @@ -0,0 +1,87 @@ +# 11 · Governance + +[← 10 Setup and stages](./10-setup-and-stages.md) · [Contents](./README.md) · Next: [12 Roles →](./12-roles.md) + +Governance is what keeps the method honest when a team runs it at speed. It is the same regardless of which AI tool writes the code, because it lives in the process and the pipeline, not in the agent. + +--- + +## The autonomy ladder + +How much the AI is allowed to do is not one switch; it is a setting chosen per area, rising with evidence and with your capacity to verify. + +| Level | The AI… | A person… | Typical use | +|-------|---------|-----------|-------------| +| **Suggest** | proposes options | decides and writes | early, exploratory work | +| **Draft-and-review** | drafts artifacts | edits and approves each one | specs, scenarios, contracts | +| **Generate-behind-gate** | generates code | reviews the change; it merges only if the contract and tests pass | the normal build | +| **Auto-with-evidence** | generates and merges | samples and audits; auto-merge allowed only with a full evidence bundle attached | narrow, well-tested areas | + +The governing rule, restated from the principles: **operate only at the level your review capacity can sustain.** If the AI produces more than the team can verify, drop a level. + +The **per-scope default is auto-with-evidence behind a one-approval decision point**: the AI drafts the specification bundle, a human approves the frozen contract once, and the build auto-gates on evidence. You *lower* a scope toward draft-and-review or suggest wherever risk is high or evidence is thin — and a high-risk or method-defining scope is *always* lowered (it is never auto-run). The default sets where you start; review capacity and risk set where you stay. + +## The gate-fail protocol and the three reports + +Every checkpoint produces three short reports — **Test** (does it pass?), **Quality** (is it well-made and conformant?), and **Risk** (what could go wrong, and who owns it?) — and resolves to exactly one outcome: + +- **`PASS`** — criteria met; proceed. +- **`RISK-ACCEPTED`** — proceed with a signed waiver carrying a named owner, a linked ticket, and an expiry. Allowed for non-security gaps only. +- **`HARD-STOP`** — cannot proceed. Triggered by any failing test or any security finding; overridable only by the most senior accountable owner, and never for security. + +The rule behind the protocol is *no silent skips.* A report nobody is accountable for approving is just a document; an outcome with an owner is governance. + +### Why each step exists (institutional memory) + +When someone proposes skipping a step "to go faster," this table is the answer: + +| Step skipped | What happens | How you notice | +|--------------|--------------|----------------| +| Specify | the wrong thing gets built | shipped, but users do not use it | +| Scenarios | the feature is vague, edges missing | the AI keeps asking questions mid-build | +| Contract | interfaces drift | front, back, and AI disagree on shapes | +| Tests | AI code is uncontrollable | no way to know it is right but to test by hand | +| Verify (architecture check) | entropy explodes | the codebase is a tangle within months | +| Operate / loop | silent rot | the same incidents recur | + +## The continuous concerns + +Four concerns are not steps but threads that run through every step, starting at project setup. Pulling them forward ("shifting left") is far cheaper than bolting them on at the end. + +| Concern | Begins at | Enforced at the build gate by | +|---------|-----------|-------------------------------| +| **Security** | setup (secret scanning, dependency allow-list) | zero high-severity findings; every AI-suggested package verified to exist | +| **Testing** | the scenarios step | coverage must not decrease; no test weakened to pass | +| **Observability** | setup (logging/metric conventions) | instrumentation present; service objectives verified after release | +| **Cost** | setup (an AI-usage budget per task) | a task may not exceed its budget without escalation | + +## AI-specific governance + +A method built on AI agents needs controls older methods did not: + +- **Pin the model.** Record the model and version; re-check the prompt library before adopting an upgrade. AI output is non-deterministic, so provenance matters. +- **Test the prompts.** The reusable instructions in `playbook/` are themselves artifacts: give each golden input/output cases, and re-check them when edited. A prompt that fails its check does not ship. +- **Guard the supply chain.** No package outside the allow-list without human approval; verify each suggested package actually exists, to defeat the risk of an agent inventing a plausible name an attacker has registered. +- **Track provenance and licensing.** License-scan both generated and pulled-in code; keep a record of what the AI produced. + +## Metrics that matter — and the anti-metrics + +Measure the scarce things: + +- **Contract stability** — how rarely the frozen contracts change; high churn is genuinely expensive. +- **Validated requirement coverage** — the share of rules confirmed against real behavior. +- **Review throughput** — the team's verification capacity, which sets the safe autonomy level. +- **Delivery and reliability** — lead time, deployment frequency, change-failure rate, time to recover. + +Do **not** optimize: lines of AI code generated, code-reuse percentage, prompt counts, or velocity measured in code volume. These count the cheap, disposable thing and create incentives to keep bad code to protect a number. + +## Profiles: one method, three intensities + +| | **Express** (startup) | **Standard** (most teams) | **Regulated** (audited) | +|---|---|---|---| +| Steps | combine Specify + Scenarios into a one-page brief; light contract | full flow | full flow, all `HARD-STOP` | +| Scenarios | happy path only | happy + key alternatives | exhaustive, incl. compliance | +| Autonomy ceiling | generate-behind-gate from day one | up to auto-with-evidence | generate-behind-gate max; the AI never merges its own work | +| Gate default | `RISK-ACCEPTED` allowed | `PASS` required to advance | `HARD-STOP`; full audit trail | + +Choose the profile deliberately — a startup spike and a banking system are not the same risk — and run different products at different profiles as appropriate. The choice is owned by the delivery lead (see [12 Roles](./12-roles.md)). diff --git a/.add/docs/12-roles.md b/.add/docs/12-roles.md new file mode 100644 index 000000000..64893cf6f --- /dev/null +++ b/.add/docs/12-roles.md @@ -0,0 +1,99 @@ +# 12 · Roles and responsibilities + +[← 11 Governance](./11-governance.md) · [Contents](./README.md) · Next: [13 Adoption →](./13-adoption.md) + +Everyone on an AIDD team becomes, in part, a *verifier*; most also become *authors of the artifacts*. This chapter says what each role owns and does. Find your section; each answers the same three questions — what you do, when, and what "done" means for you. + +--- + +## Product / Domain Owner + +- **Mission:** ensure the right thing gets built. You guard the problem. +- **Leads:** Specify. **Contributes to:** Scenarios; the loop (deciding what the next cycle addresses). +- **Owns:** the problem definition, the glossary of domain terms, the prioritized backlog. +- **Done means:** the spec states real user value with no disputed terms and its assumptions ranked lowest-confidence first — the one or two most likely wrong flagged with *why* and *what they cost*; after release, you have decided what the next loop must address. +- **Apply it:** run the Specify prompt against a real ticket or interview, then read the AI's lowest-confidence flag *first* and decide the one or two load-bearing assumptions before skimming the low-stakes tail. If you cannot confirm a load-bearing rule, it is not ready to build. + +## Architect / Engineering Lead + +- **Mission:** own the load-bearing surfaces and the checks that protect them. +- **Leads:** project setup; the Contract freeze. **Accountable for:** all the durable artifacts. +- **Owns:** `CONVENTIONS.md`, the contracts, the architecture check in verification, the model record. +- **Done means:** contracts are frozen and versioned; the architecture check runs in the pipeline; autonomy levels match the team's real review capacity. +- **Apply it:** treat the contract freeze as a one-way door. When a stream wants to change a frozen contract, route it as a change request that reopens Specify — never let code quietly move the surface. + +## Software Engineer (Senior) + +- **Mission:** direct the build and hold quality at the architecture check. +- **Leads:** Build. **Contributes to:** Contract, Tests; reviews others' changes. +- **Owns:** the implementation, the architecture conformance check, the evidence bundle on each change. +- **Done means:** all tests pass without any test being weakened; coverage holds; architecture and security checks pass; a person has reviewed it. +- **Apply it:** work in small batches the review can keep up with, and never let the AI edit a test to make it pass — that is the cardinal sin of the build step. + +## Software Engineer (Junior) + +- **Mission:** learn the craft by entering at the build end and growing toward judgment. +- **Leads:** nothing yet. **Contributes to:** Build (against handed-over specs and contracts), Tests. +- **Owns:** your tasks' code and tests; raising a flag when a spec is ambiguous — which is a contribution, not a failure. +- **Done means:** your task's tests pass honestly, your change has a clear evidence bundle, and a senior has reviewed it. +- **Apply it:** start with specs and contracts given to you and make red tests green without weakening them; over time move *up* toward design and specification as your judgment matures (see [13 Adoption](./13-adoption.md)). + +## QA / Test Engineer + +- **Mission:** make "done" machine-checkable; you are the guardrail for AI-written code. +- **Leads:** Tests. **Contributes to:** Scenarios (turning rules into checkable form); the loop (production monitors). +- **Owns:** the test suite, the scenario files, the coverage target, the test report at each gate. +- **Done means:** every scenario has a test that was red before the build; the suite is honest (nothing passes by default); coverage never regresses. +- **Apply it:** co-author the scenarios so the path from rule to test loses nothing, and confirm the suite fails for the *right* reason before the build begins. + +## Product Designer (UI/UX) + +- **Mission:** ensure correct logic does not ship inside a poor experience. +- **Leads:** the design portion of Specify; the Prototype stage. **Contributes to:** Scenarios (experience-side rules). +- **Owns:** the user flows, the specification of every screen state, the design document, the clickable prototype. +- **Done means:** every screen has all its states designed; the prototype matches the scenarios; the self-critique for generic, low-effort output has passed. +- **Apply it:** in the Prototype stage you lead — make the experience tangible fast, and carry the design forward while the prototype code is discarded. + +## DevOps / SRE / Platform + +- **Mission:** make the continuous concerns real and run the operate-and-learn loop. +- **Leads:** the loop / operations. **Contributes to:** setup (pipeline, observability conventions), Build (deployment, gradual delivery). +- **Owns:** gate enforcement in the pipeline, telemetry conventions, service-objective dashboards, rollback, the cost budget. +- **Done means:** the gate outcomes are enforced mechanically in the pipeline; instrumentation is required to pass the build gate; rollback is tested; objectives are observed after release. +- **Apply it:** wire the gate-fail protocol into the pipeline so a `HARD-STOP` is automatic, not a meeting, and shift security checks to setup rather than the end. + +## Security Engineer + +- **Mission:** keep AI-written code from importing AI-shaped risk. +- **Leads:** the security thread. **Contributes to:** setup (allow-list, secret scanning), Specify (threat modeling), Build (scanning), AI governance. +- **Owns:** the dependency allow-list, the provenance and license record, the security report at each gate, the supply-chain policy. +- **Done means:** zero high-severity findings at the build gate; every AI-suggested dependency verified real and intended; generated and pulled-in code license-scanned. +- **Apply it:** assume the AI will at some point hardcode a secret and invent a package name; gate against both from setup, and keep security findings as `HARD-STOP`, never waivers. + +## Engineering Manager / Delivery Lead + +- **Mission:** match intensity to risk, and protect verification capacity. +- **Leads:** profile selection and stage planning. **Contributes to:** unblocking every step; the loop (priorities). +- **Owns:** the chosen profile, the stage roadmap, the metrics dashboard. +- **Done means:** the team operates at an autonomy level its review capacity can sustain; metrics track the scarce things, not code volume; each stage exits on its real achievement, not a date. +- **Apply it:** choose the profile deliberately, and watch review throughput as the true measure of velocity — if AI output outpaces review, slow the engine rather than rushing the review. + +--- + +## Responsibility matrix + +`A` Accountable · `R` Responsible/Lead · `C` Consulted · `I` Informed + +| Role | Setup | Specify | Scenarios | Contract | Tests | Build | Verify | Loop | +|------|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:| +| Product / Domain | C | **R** | R | I | I | I | I | R | +| Architect / Lead | **R/A** | C | C | **R/A** | C | A | A | C | +| Engineer (Senior) | C | I | C | R | R | **R** | R | C | +| Engineer (Junior) | I | I | I | I | R | R | I | I | +| QA / Test | I | C | R | C | **R** | C | C | C | +| Designer | I | R (design) | R | C | I | I | I | I | +| DevOps / SRE | R | I | I | C | C | R | R | **R** | +| Security | R | C | I | C | C | R | R | C | +| EM / Delivery | C | C | C | C | C | C | C | C | + +> If your role is only ever `I`, you are not yet using the method — find the step where your judgment *is* the gate. diff --git a/.add/docs/13-adoption.md b/.add/docs/13-adoption.md new file mode 100644 index 000000000..762b03e8b --- /dev/null +++ b/.add/docs/13-adoption.md @@ -0,0 +1,67 @@ +# 13 · Adoption and onboarding + +[← 12 Roles](./12-roles.md) · [Contents](./README.md) · Next: [14 The foundation →](./14-foundation.md) + +How a team starts using AIDD, and how a new person becomes productive in it. + +--- + +## A 90-day rollout + +Adopt the method on one real product, not as an all-at-once mandate. + +1. **Days 1–15 — Lock the foundation.** On one pilot service, let the AI draft the foundation — conventions, glossary, dependency allow-list, model record — from the existing code (or the four-lens interview if greenfield), then **lock it down** with one signature. The prompt playbook is [Appendix B](./appendix-b-prompts.md). +2. **Days 16–45 — One feature, end to end.** Run a single feature through the whole flow at the **Express** profile. Capture friction; tune the prompts' golden cases as you go. +3. **Days 46–75 — Turn on the gates.** Wire the three reports and the gate-fail protocol into the pipeline; introduce the autonomy ladder at the generate-behind-gate level. +4. **Days 76–90 — Promote.** Move the pilot to the **Standard** profile, draft the **Regulated** variant for any compliance-bound product, and publish the prompts as a shared, versioned playbook. + +## Choosing a profile + +| Choose… | When… | +|---------|-------| +| **Express** | startup, spike, or internal tool; speed of learning dominates; small scope of impact | +| **Standard** | a normal product with real users and ordinary risk | +| **Regulated** | finance, health, or anything audited; failure is expensive or legally consequential | + +The choice is deliberate and owned by the delivery lead; different products can run at different profiles. + +## Onboarding: enter from the build end + +The most common onboarding mistake is to start newcomers at the most abstract step. Specification and domain discovery require judgment a newcomer has not yet built. So bring people in from the *concrete* end and move them toward judgment: + +1. **Weeks 1–4 — Build and Tests.** Implement tasks against specs and contracts handed to you; write tests. Learn the architecture check and the evidence bundle. +2. **Weeks 5–8 — Contract and design.** Start contributing to contracts and screen states; learn why the surface is frozen. +3. **Weeks 9–12 — Scenarios and Specify.** Co-author scenarios and specs; practice removing ambiguity. +4. **Beyond — Domain discovery.** The most abstract work comes last, once judgment is calibrated. + +You graduate *up* the flow, from execution toward direction. Deciding what to build is the senior skill, not the entry skill. + +## Tool portability + +The prompts are plain text that reference files in the repository, and the gates are enforced in the pipeline, not in the agent. So the method does not depend on any one AI coding tool — the agent is replaceable, the method is not. A conformant prompt is (1) tool-agnostic plain language, (2) anchored to repository files rather than chat memory, (3) self-describing about which model and exit criteria it assumes, and (4) checkable by the pipeline. + +| Concern | Where it lives | +|---------|----------------| +| Prompt discovery | a folder convention in the repo (`playbook/`) | +| Context | repository files the prompt names explicitly | +| Gate enforcement | the build pipeline | + +Switching tools changes the discovery convention and nothing structural. + +## First week, by role + +| Role | First-week task | +|------|------------------| +| Product / Domain | run the Specify prompt on a real input; produce a glossary you would defend | +| Architect / Lead | review the AI's setup draft and lock it down (the first contract freezes with it); wire the architecture check into the pipeline | +| Engineer (Senior) | run the Build prompt on one small task; produce a full evidence bundle | +| Engineer (Junior) | take a handed-over spec; make a red test green without weakening it | +| QA / Test | convert one rule into a scenario, then a failing test | +| Designer | take a spec; produce flows, all screen states, and a clickable prototype | +| DevOps / SRE | wire one gate report into the pipeline; add secret-scan and allow-list to setup | +| Security | build the dependency allow-list; make security findings a `HARD-STOP` | +| EM / Delivery | choose the pilot's profile; stand up the review-throughput metric | + +--- + +> Adoption is a loop too. The method itself is a living document: every cycle should feed improvements back into your copy of these prompts and conventions. diff --git a/.add/docs/14-foundation.md b/.add/docs/14-foundation.md new file mode 100644 index 000000000..e69078c03 --- /dev/null +++ b/.add/docs/14-foundation.md @@ -0,0 +1,129 @@ +# 14 · The foundation: project context across milestones + +[← 13 Adoption](./13-adoption.md) · [Contents](./README.md) · Next: [15 Foundations & Lineage →](./15-foundations-and-lineage.md) + +--- + +## The engine needs ground + +The flow in [Part II](./02-the-flow.md) is the *engine*: Specify → Scenarios → +Contract → Tests → Build → Verify, run as a tight loop. TDD and ADD turn inside +that engine — write the failing test, let the AI generate code, repeat. + +But an engine needs something to stand on. Every loop quietly assumes context that +no single task owns: *what the words mean*, *what we are building right now*, and +*how its users experience it*. When that context lives only in someone's head, each new session — +and each new milestone — starts cold, and the AI fills the gap with plausible +guesses. That is the same failure the method exists to prevent ([00](./00-introduction.md)), +one level up. + +The **foundation** is the layer that holds this context and *outlives every +milestone*. It is not new ceremony; it is the [living documentation](./appendix-f-requirements-matrix.md) +the method already names, made explicit as three concerns. + +## Three concerns, one foundation + +![The engine needs ground — the TDD ⇄ ADD engine runs on a DDD · SDD · UDD foundation: context feeds up, and any loop may send a correction back down](./add-foundation.png) + +- **DDD — Domain.** The shared, precise language and the boundaries it lives in: + the core concepts, the modules/contexts they belong to, and the invariants that + must always hold — the domain model and context map behind the names. One name + per concept — the same names the spec, the contract, and the code all use. (The + [GLOSSARY](./appendix-c-glossary.md) holds the full term list; the foundation + holds the model those terms describe.) + +- **SDD — Spec.** *The living document.* What is being built right now and what is + settled versus still open. This is not a frozen plan written once — it is the + layer that changes as the loop learns ([01](./01-principles.md)). In ADD it does + not duplicate the work; it **points** to the active milestone and the frozen + contracts that other tasks build against. + +- **UDD — UI/UX.** *Users use the interface, not the spec.* The experience designed + before code: the **user flows** (happy and alternative paths), the **UI states** + every screen must handle (loading · empty · error · success), and a design source + of truth — a `DESIGN.md` or clickable prototype. The AI can generate a prototype + from a design system; a person owns the empathy — what the user is trying to do, + and what "good" feels like from their side. The scenarios ([04](./04-step-2-scenarios.md)) + test that behaviour; the foundation keeps the design intent that makes a screen + worth building. + +These three foundation competencies, together with the **TDD ⇄ ADD** engine of +[Part II](./02-the-flow.md), are ADD's five. The first four feed context to the +fifth, where the AI executes on it: + +![ADD's five competencies — DDD · SDD · UDD · TDD · ADD: the first four are human-led and feed context to ADD, which is AI-led under your direction](./add-competencies.png) + +> The diagram's foundation (DDD · SDD · UDD) and the method's own words — living +> documentation · the foundation document · ubiquitous language — name the same three ideas. This +> chapter is where the diagram and the text finally meet. + +## One file, not three + +A foundation that takes a week to write is a foundation no one keeps current. So +ADD realizes all three concerns as **one living document — `PROJECT.md`** — with +one short section each, plus an append-only record of key decisions: + +``` +.add/PROJECT.md + ## Domain (DDD) — concepts · contexts · invariants + ## Spec / Living Document (SDD)— → active milestone + frozen contracts + ## Users (UDD) — UI/UX: user flows · states · DESIGN.md / prototype + ## Key Decisions — append-only: date · decision · why · outcome +``` + +Keep it to one screen. If a section wants to grow into a manual, that is a signal +the detail belongs in a milestone or a contract, not the foundation. The foundation +is the *thin, durable* context the engine reads first — not a place to relocate the +work. And you do not hand-write it: at setup the AI **drafts** all four sections — +silently from an existing codebase, or from a short four-lens interview on a +greenfield repo — and a single human **baseline approval** freezes that draft as committed +direction (the setup-level analog of a contract freeze). + +## How it feeds the engine — and takes feedback back + +The arrow runs both ways, which is the whole point of a re-entrant method: + +- **Down → up.** At the start of any session or milestone, read `PROJECT.md` + before touching a task. It is the cheapest way to point the AI in the right + direction. `add.py status` prints a pointer to it for exactly this reason. +- **Up → down.** When a loop reveals that the domain model was wrong, the spec + stance has shifted, or a user assumption did not survive contact with reality, + you **stop and update the foundation** — then come forward again. A passing test + built on a broken foundation is still the wrong software, fast. + +## Where it sits in the hierarchy + +The foundation is the **Project tier** of the document hierarchy +([Appendix F](./appendix-f-requirements-matrix.md)) — created once, kept for the +life of the product, owned above any single milestone. + +![Three tiers of documents — Project (the foundation, .add/PROJECT.md) → Milestone → Task: scope narrows and lifespan shortens down the stack](./add-hierarchy.png) + +| Tier | Lives in | Lifespan | Holds | +|------|----------|----------|-------| +| **Project** (foundation) | `.add/PROJECT.md` + living-doc files | whole product | domain, spec stance, users, decisions | +| **Milestone** | `.add/milestones//MILESTONE.md` | one depth-bounded goal | scope, shared contracts, exit criteria | +| **Task** | `.add/tasks//TASK.md` | one feature | the seven-step artifacts | + +A milestone is a *version bump* to the foundation, not a fresh start: when it +closes, consolidate what it validated into `PROJECT.md` (a decision, a settled domain +term, a confirmed user journey) and open the next one against the same, now-richer, +ground. The consolidation is not informal: each loop emits **lessons learned** (tagged +`DDD · SDD · UDD · TDD · ADD`) in its Observe step, and at milestone close a person +gathers the open ones and consolidates them — append-only, with the `foundation-version:` +bumped — into the foundation. See [09 · The loop](./09-the-loop.md#lessons-learned-and-the-retrospective-consolidation) +for the grammar, the ritual, and the tooling (`add.py deltas`, `add.py check`). + +## In the tooling + +- `add.py init` scaffolds `PROJECT.md` as a living-doc file; the AI then drafts its + content and a single human **baseline approval** (`add.py lock`) freezes it. Like every + living-doc file, `init` **never overwrites a hand-edited one**. +- `add.py status` shows a one-line pointer to the foundation, so a fresh session + re-orients on context before code. +- The guideline block written into `CLAUDE.md` / `AGENTS.md` tells any agent the + same thing: run `status`, read the foundation, then work the loop. + +> **The thesis, one level up.** The engine builds the thing right; the foundation +> keeps the engine pointed at the right thing — across every milestone, not just +> the current one. diff --git a/.add/docs/15-foundations-and-lineage.md b/.add/docs/15-foundations-and-lineage.md new file mode 100644 index 000000000..18fd10916 --- /dev/null +++ b/.add/docs/15-foundations-and-lineage.md @@ -0,0 +1,106 @@ +# 15 · Foundations & Lineage + +[← 14 The foundation](./14-foundation.md) · [Contents](./README.md) · Next: [Appendix A Templates →](./appendix-a-templates.md) + +--- + +ADD did not appear from nowhere. It sits where four currents meet: the **recursive +self-improvement** thesis (AI that helps build the next AI), a decade of **autonomous and +agentic** research, the **spec-driven development** movement (the specification, not the +code, is the source of truth), and the **tests-first** discipline that constrains a +generate→check→refine loop with executable tests — turning fluent model output into +trustworthy software. This chapter tells that story; [Appendix G](./appendix-g-references.md) +is the verified source list it cites into. Every `[Author Year]` here resolves to an entry +there. + +## The frame — "closing the loop" + +Anthropic's recursive-self-improvement picture runs from autonomous agents delegating to +workers *today* toward a future where Claude improves Claude — *closing the loop* on the +work of building AI itself [Favaro & Clark 2026]. That is the backdrop ADD is built for, and +its position inside that picture is deliberately narrow: ADD is a **human-gated, +evidence-trusted** instance of recursive self-improvement. The AI drives the whole inner +cycle — specify → build → verify → observe — but a human owns the frozen contract and the +verify gate, and trust comes from passing tests and re-resolved evidence, never from a +diff that merely reads plausibly. The argument is not that the loop should stay open +forever; it is that the loop should be *bounded by human direction* rather than left to run +unattended [Amodei 2024]. ADD is one concrete shape for that bound. + +## The four currents + +**Recursive self-improvement.** The mathematical anchor is the Gödel machine — a +self-modifying agent that rewrites itself *only when it can prove the rewrite helps* +[Schmidhuber 2003]. ADD enforces the same discipline socially rather than formally: the +never-weaken-a-test rule is "only change on proof" expressed as a gate. The algorithmic kin +arrived later — a scaffolding program that improves the code that improves code +[Zelikman et al. 2023], a generate→critique→refine micro-loop [Madaan et al. 2023], agents +that keep verbal reflections and retry [Shinn et al. 2023], an agent that grows a reusable +skill library over time [Wang et al. 2023], and an evolutionary coder that beat a +long-standing matrix-multiplication record under continuous checking +[Novikov et al. 2025]. And where a self-rewarding loop has the model judge its own reward +[Yuan et al. 2024], ADD diverges by design — it makes the tests and a human the reward +signal, not the model's own opinion. + +**Autonomous and agentic workflows.** The architecture vocabulary comes from the canonical +taxonomy of prompt-chaining, routing, orchestrator-workers, and the evaluator-optimizer loop +[Schluntz & Zhang 2024] — where evaluator-optimizer *is* build→verify→refine and +orchestrator-workers is ADD's wave parallelism. Underneath it sit the base agent loop of +interleaved think→act→observe [Yao et al. 2022], the self-supervised tool use that lets an +agent run its own tests and builds [Schick et al. 2023], and the designed agent–computer +interface that materially lifts autonomous issue resolution [Yang et al. 2024] — the role +ADD's `add.py` engine plays for the method. The production reports close the gap from theory +to practice: checkpoints, subagents, and rollback for autonomous work [Anthropic 2025a], and +a lead orchestrating subagents under an LLM judge [Anthropic 2025b]. + +**Spec-driven development.** ADD's closest siblings are explicit specification systems. +GitHub's **spec-kit** runs `constitution` → `specify` → `plan` → `tasks` → `implement` with +the spec as the executable source of truth [GitHub 2025]; its launch framed task +decomposition as "TDD for your AI agent" [Delimarsky 2025], and its rationale named the +failure spec-driven work exists to solve — context degrading over a long session +[Vesely 2025]. The academic vocabulary followed, with a taxonomy of Spec-First, +Spec-Anchored, and Spec-as-Source rigor [Piskala 2026], and the pattern is converging across +vendors [InfoQ 2025]. Nearest of all is **GSD** — a spec-driven, context-engineering system +for the same Claude-Code niche [GSD 2025]. + +**Tests-first and verification.** The empirical backbone is direct: supplying tests +alongside the prompt measurably lifts pass rates [Mathews & Nagappan 2024], and the field's +yardstick judges a fix solely by whether the project's own tests pass [Jimenez et al. 2023]. +"Done" means the tests pass — which is exactly how ADD gates a feature. The safety framing +completes the current: human control and transparency made concrete [Anthropic 2025c], under +a governance ceiling that grows *more* binding, not less, as the loop gets more capable +[Anthropic 2026b]. + +## Where ADD diverges + +The shared lineage is real, but ADD is not a re-skin of its siblings. spec-kit stops at +`implement`; GSD ends at verify. ADD closes the loop past both by adding three things +neither spec-kit [GitHub 2025] nor GSD [GSD 2025] carries as a first-class gate: + +- a **failing-tests-first gate** — no build starts until the tests are red for the right + reason, so the contract is proven executable before any code exists; +- an **observe → `fold`** step — confirmed lessons learned consolidate back into a versioned + foundation, so the method improves itself across loops (retrospective consolidation is the + recursive-self-improvement current turned inward on ADD); +- a **dynamic goal-loop** — the engine holds a milestone open and reopens tasks until its + exit criteria are met, rather than declaring done when a checklist empties. + +ADD also deliberately targets **less doc-time than GSD** — a lean foundation and one human +approval per task instead of a document per phase. The tests-first gate, the `fold`, and the +goal-loop are ADD's contribution; everything beneath them is inherited. + +## The evidence chain — the loop already runs + +The case that this is not speculative rests on three measured facts. First, the task +time-horizon: the length of work models complete unaided keeps doubling [Favaro & Clark 2026]. +Second, the authorship share: by 2026 more than 80% of the code merged at Anthropic was +Claude-authored [Favaro & Clark 2026]. Third, the **Automated Alignment Researchers** result: +nine parallel Claude agents recovered roughly 97% of the human-expert gap on an alignment task +in five days against the human team's seven [Anthropic 2026a] — parallel agents working under +review, which is precisely ADD's wave-plus-verify shape. The loop already runs. + +What it does *not* yet supply is the discipline to trust the output. That is ADD's +contribution: the frozen contract, the never-weaken-a-test rule, the evidence-over-inspection +gate, and the security HARD-STOP that no autonomy level may auto-pass [Anthropic 2025c], +held beneath the responsible-scaling governance ceiling [Anthropic 2026b]. As the loop grows +more capable, those gates and the human-owned verify matter more, not less. ADD is the human-gated, evidence-trusted way to stand inside the +closing loop and still own the result. diff --git a/.add/docs/README.md b/.add/docs/README.md new file mode 100644 index 000000000..71b9bed77 --- /dev/null +++ b/.add/docs/README.md @@ -0,0 +1,74 @@ +# AI-Driven Development + +### A complete, practical book on building software when AI writes the code + +**Edition:** 1.0 · **Type:** Methodology + operating manual + +--- + +## What this book is + +This is a complete guide to **AIDD (AI-Driven Development)** — a way of building software in which an AI agent writes most of the code and people do the two things AI cannot reliably do alone: decide *what* to build, and *verify* that what was built is correct. + +It is written to be read once front to back, then kept open beside you as a working manual. The early chapters explain *why* the method has the shape it does; the middle chapters explain each step in detail; the later chapters explain how to operate it across a real team and product; the appendices are copy-paste reference material. + +A single worked example — *transferring money between a user's own accounts* — runs through the entire book so that every abstract step has a concrete form you can see. + +## Who it is for + +Anyone who builds software with AI in the loop: engineers, architects, testers, designers, product owners, and the managers who lead them. No part assumes you have read the others; cross-references point you to what you need. + +## The method in one paragraph + +For every feature, before AI writes any code, you write four short artifacts in order — the rules it must obey, those rules as pass/fail scenarios, the data and interface contract, and the failing tests — and then you direct the AI to make the tests pass without changing them, and finally you verify the result through evidence rather than inspection. That ordered set of artifacts *is* the method. The code is disposable; the artifacts are the durable asset. Direction comes before speed, and trust comes from passing tests rather than from reading code and finding it plausible. + +## The flow + +> **Specify → Scenarios → Contract → Tests → Build → Verify → observe, then repeat.** + +--- + +## Table of contents + +**Part I — Foundations** +- [00 · The shift: why AIDD exists](./00-introduction.md) +- [01 · Core principles](./01-principles.md) +- [02 · The flow, and what is disposable](./02-the-flow.md) + +**Part II — The method, step by step** +- [03 · Step 1 — Specify](./03-step-1-specify.md) +- [04 · Step 2 — Scenarios](./04-step-2-scenarios.md) +- [05 · Step 3 — Contract](./05-step-3-contract.md) +- [06 · Step 4 — Tests](./06-step-4-tests.md) +- [07 · Step 5 — Build](./07-step-5-build.md) +- [08 · Step 6 — Verify](./08-step-6-verify.md) +- [09 · The loop — observe and learn](./09-the-loop.md) + +**Part III — Operating the method** +- [10 · Project setup and stages](./10-setup-and-stages.md) +- [11 · Governance](./11-governance.md) +- [12 · Roles and responsibilities](./12-roles.md) +- [13 · Adoption and onboarding](./13-adoption.md) +- [14 · The foundation: project context across milestones](./14-foundation.md) + +**Lineage** +- [15 · Foundations & Lineage](./15-foundations-and-lineage.md) + +**Part IV — Reference** +- [Appendix A · Templates](./appendix-a-templates.md) +- [Appendix B · Prompt library](./appendix-b-prompts.md) +- [Appendix C · Glossary](./appendix-c-glossary.md) +- [Appendix D · The worked example, end to end](./appendix-d-worked-example.md) +- [Appendix E · Checklists](./appendix-e-checklists.md) +- [Appendix F · Document requirements matrix (Project → Milestone → Task)](./appendix-f-requirements-matrix.md) +- [Appendix G · References & lineage](./appendix-g-references.md) + +--- + +## Conventions used in this book + +- **▶ Example** marks the running worked example. +- **Do / Don't** boxes give the rule in its shortest form. +- A **gate** is a checkpoint with an explicit pass/fail exit. Its outcome is always one of `PASS`, `RISK-ACCEPTED` (a signed waiver), or `HARD-STOP`. +- File names like `SPEC.md`, `features/*.feature`, `contracts/*` refer to the artifacts you create per feature; see [Appendix A](./appendix-a-templates.md). +- Where this book uses a plain step name, the formal phase name (for teams mapping to a larger standard) appears once in [Appendix C](./appendix-c-glossary.md). diff --git a/.add/docs/add-competencies.png b/.add/docs/add-competencies.png new file mode 100644 index 000000000..3afaeb1fe Binary files /dev/null and b/.add/docs/add-competencies.png differ diff --git a/.add/docs/add-flow.png b/.add/docs/add-flow.png new file mode 100644 index 000000000..004746654 Binary files /dev/null and b/.add/docs/add-flow.png differ diff --git a/.add/docs/add-foundation.png b/.add/docs/add-foundation.png new file mode 100644 index 000000000..4a2023e5b Binary files /dev/null and b/.add/docs/add-foundation.png differ diff --git a/.add/docs/add-hierarchy.png b/.add/docs/add-hierarchy.png new file mode 100644 index 000000000..de3ed9dfb Binary files /dev/null and b/.add/docs/add-hierarchy.png differ diff --git a/.add/docs/appendix-a-templates.md b/.add/docs/appendix-a-templates.md new file mode 100644 index 000000000..056915db4 --- /dev/null +++ b/.add/docs/appendix-a-templates.md @@ -0,0 +1,88 @@ +# Appendix A · Templates + +[← 15 Foundations & Lineage](./15-foundations-and-lineage.md) · [Contents](./README.md) · Next: [Appendix B Prompts →](./appendix-b-prompts.md) + +Copy-paste blanks. Project-level templates are filled once at setup; feature-level templates are filled once per feature. + +--- + +## Project-level (set up once) + +### `CONVENTIONS.md` +``` +Language/framework: +Folders: src/ tests/ contracts/ features/ playbook/ +Naming: , , verbs for functions +Lint/format: , enforced in the pipeline +Errors: machine-readable error codes (string enums), never free text +Architecture: +``` + +### `MODEL_REGISTRY.md` +``` +Model: +Version: +Adopted: +Notes: re-run the prompt golden-cases before changing this. +``` + +### `dependencies.allowlist` +``` +# one package per line; the pipeline rejects anything not listed +== +``` + +--- + +## Feature-level (once per feature) + +### `SPEC.md` +``` +Feature: +Framings weighed: (chosen) · · +Must: + - +Reject: + - -> "" +After: + - +Assumptions — lowest-confidence first: + ⚠ — lowest confidence because ; if wrong: + - [x] +``` + +### `features/.feature` +``` +Scenario: + Given + When + Then + And # when relevant +``` + +### `contracts/.md` +``` + body: { } + 200 -> { } + 4xx -> { error: "" | "" } +Schema: +Status: FROZEN @ v +``` + +### `tests/_test.` (stub) +``` +test_: + arrange: + act: + assert: + assert: # when relevant +``` + +### Gate outcome record +``` +Feature: Step: Date: +Reports: Test= Quality= Risk= +Outcome: +If RISK-ACCEPTED -> owner: ticket: expires: +Reviewed by: +``` diff --git a/.add/docs/appendix-b-prompts.md b/.add/docs/appendix-b-prompts.md new file mode 100644 index 000000000..830ed1fc0 --- /dev/null +++ b/.add/docs/appendix-b-prompts.md @@ -0,0 +1,154 @@ +# Appendix B · Prompt library + +[← Appendix A Templates](./appendix-a-templates.md) · [Contents](./README.md) · Next: [Appendix C Glossary →](./appendix-c-glossary.md) + +The contents of the `playbook/` folder. Each prompt is plain text that names the files to read, states a single task, and lists the rules. The inline `# why:` notes are annotations — keep them; they encode the judgment behind each instruction. These prompts are themselves versioned, tested artifacts (see [11 Governance](./11-governance.md)). + +--- + +### `playbook/1_specify.md` + + + +``` +Role: a domain analyst who brainstorms, then asks rather than assumes. +Read first: ./PRD/* , ./GLOSSARY.md , ./inputs/ (tickets, interviews, contracts) +Task: co-specify SPEC.md WITH me. No solutions, no code. +Steps: + 0. Diverge first: surface 2–3 genuine framings of the feature + the open questions, and let + me react before you draft. Record the result as `Framings weighed: X (chosen) · Y · Z`. + # why: a spec dictated by one side is a guess; brainstormed, it is a decision. + 1. List every required behavior (Must) and every situation to refuse (Reject), + giving each refusal a named error code. + # why: named errors become scenarios and contract responses; "handle bad input" does not. + 2. State the success state-change (After). + 3. List the assumptions you had to make, RANKED lowest-confidence first; flag the 1–2 where + your confidence is lowest as `⚠ — lowest confidence because ; if wrong: `. + # why: a flat all-equal list gets approved without reading; a ranked one aims my attention at the risk. +Exit: a domain owner disputes none of it; assumptions ranked lowest-confidence first, the 1–2 ⚠ flags + carrying why + cost — or an honest "none material" that still names the single biggest risk. +Never: resolve an ambiguity by guessing — ask. Never a blank "none" or a flat list of equal ticks. +``` + + + +### `playbook/2_scenarios.md` + + + +``` +Role: a specification tester. +Read first: ./SPEC.md , ./GLOSSARY.md +Task: produce features/.feature. +Steps: + 1. For each Must and each Reject rule, write a Given/When/Then scenario. + # why: a rule with no scenario will never be verified. + 2. For every rejection, add an And-clause asserting what must NOT change. + # why: catches corrupting partial failures that a result-only check misses. +Exit: every rule has at least one scenario with an observable result. +Never: write a vague result ("then it works"). +``` + + + +### `playbook/3_contract.md` + + + +``` +Role: an interface/contract architect; contracts are immutable once frozen. +Read first: ./SPEC.md , ./features/*.feature , ./GLOSSARY.md +Task: produce contracts/.md, a mock server, and contract tests. No business logic. +Steps: + 1. Define interfaces, request/response shapes, and the schema, named from the glossary. + # why: consistent names prevent the subtle mismatches that cause silent bugs. + 2. Define a response for every Reject error code in the spec. + 3. Generate a mock returning the contracted shapes, and contract tests pinning them. + # why: the mock unblocks dependent work; the tests become a regression baseline. + 4. Mark the contract FROZEN at a version. +Exit: contract tests pass against the mock; every spec rejection has a response. +Never: change a frozen contract — a change is a request that reopens Specify. +``` + + + +### `playbook/4_tests.md` + + + +``` +Role: a test author who writes tests before code. +Read first: ./features/*.feature , ./contracts/* +Task: produce a failing (red) test suite. Do NOT implement the feature. +Steps: + 1. Turn each scenario into an executable test. + # why: closes spec -> scenario -> test with no human translation loss. + 2. Add contract-conformance and edge-case tests. + 3. Run the suite; confirm it fails for the right reason (missing implementation). + # why: a test that passes before code exists is testing nothing. + 4. Record a coverage target. +Exit: one test per scenario; suite red for the right reason; target recorded. +Never: assert on internals; write the implementation here. +``` + + + +### `playbook/5_build.md` + + + +``` +Role: an execution agent. The human commands; you implement and report. +Read first: ./SPEC.md , ./contracts/* , ./tests/* , ./CONVENTIONS.md +Task: make EVERY failing test pass, one small task at a time. +Steps: + 1. Pick ONE task; restate the tests it must satisfy before coding. + # why: small batches keep human review able to keep up. + 2. Implement; run tests; iterate to green WITHOUT weakening any test. + # why: editing a test to pass makes the code judge itself — the cardinal sin. + 3. Honor the feature-specific safety rule (e.g. atomic balance update). + 4. Run security and allow-list checks; attach the evidence bundle; open the change. +Exit: all green; coverage held; no test/contract changed; no out-of-allow-list package. +Never: change a test or the contract; add an unlisted dependency; exceed the task budget + without escalating; guess when unclear — ask. +``` + + + +### `playbook/6_observe.md` + + + +``` +Role: a reliability analyst feeding the next cycle. +Read first: telemetry exports , service-objective definitions , incident tickets +Task: turn production reality into the next SPEC delta. +Steps: + 1. Report objective status and error-budget burn vs target. + 2. Cluster errors and usage; surface the top real-world failures. + 3. Draft a SPEC delta — what the next loop should add or fix — with evidence links. + # why: closes the loop; production learning becomes the next specification. +Exit: a reviewed SPEC delta linked into the backlog. +Never: auto-roll back — recommend; a human owns the production decision. +``` + + + +--- + +### Master prompt skeleton + + + +``` +Role: +Read first: +Task: +Steps: + 1. # why: +Exit: +Never: +Evidence: +``` + + diff --git a/.add/docs/appendix-c-glossary.md b/.add/docs/appendix-c-glossary.md new file mode 100644 index 000000000..d9af050b2 --- /dev/null +++ b/.add/docs/appendix-c-glossary.md @@ -0,0 +1,115 @@ +# Appendix C · Glossary + +[← Appendix B Prompts](./appendix-b-prompts.md) · [Contents](./README.md) · Next: [Appendix D Worked example →](./appendix-d-worked-example.md) + +--- + +## Terms + +**AIDD (AI-Driven Development)** — a method of building software in which an AI agent writes most of the code and people direct and verify the work. + +**Artifact** — a durable work product: the spec, the scenarios, the contract, the tests. The artifacts survive; the code is disposable. + +**Lesson learned** (formerly "competency delta") — a single learning a loop produces, tagged by which of the five competencies (`DDD · SDD · UDD · TDD · ADD`) it improves, written in a task's OBSERVE phase as `- [ · ] (evidence: …)`. Emitted `open` by the AI; the human folds it into a versioned `PROJECT.md` (`folded`) or declines it (`rejected`). The mechanism by which the foundation self-improves instead of drifting. See the `add` skill's `deltas.md`. + +**Contract** — the fixed external shape of a feature: interfaces, data structures, names, and error cases. Frozen before the build, it is the surface the AI builds against. + +**Co-specification** — how a spec is made in ADD: the AI and the human **brainstorm the shape together** (diverge), the AI **drafts** it, and the human **validates with the AI's advice** (validate). The AI's decisive advice is the *lowest-confidence flag*. It replaces dictation-by-one-side — the human owns the decision, the AI owns surfacing what it does not yet know. See [03 Specify](./03-step-1-specify.md). + +**Disposable code** — the view that code is one regenerable implementation of the artifacts, not a durable asset to be preserved. + +**Evidence bundle** — the proof attached to a change (passing tests, clean security scan, no coverage loss) that justifies trusting it and may unlock more AI autonomy. + +**Foundation version** — a monotonic integer marker in `PROJECT.md` that advances by one each time confirmed lessons learned are consolidated into the foundation. It makes the living documentation's evolution auditable: a rising version with fewer new deltas per milestone is the signal that a competency is converging rather than drifting. Bumped only by the retrospective consolidation (see the `add` skill's `fold.md`). + +**Gate** — a checkpoint with an explicit pass/fail exit. Its outcome is `PASS`, `RISK-ACCEPTED`, or `HARD-STOP`. + +**`HARD-STOP`** — a gate outcome meaning work cannot proceed; triggered by any failing test or security finding. + +**Intake** — the step *before* a task: sizing a raw request into versioned scope by classifying it into one **request bucket**. The AI proposes `{bucket, rationale, command}`; the human confirms. Lives in the `add` skill's `intake.md` (the intake level, above the per-task flow). + +**Lowest-confidence flag** (formerly "least-sure flag") — the AI's ranked declaration of the **1–2 things most likely to be wrong** in what it is asking a human to approve, each carrying *why* it is uncertain and *what it costs if wrong* (`⚠ [spec|scenario|contract|test] … — because …; if wrong: …`). It reshapes the old flat assumptions list into a ranked one, so a single approval aims the reviewer's attention at the real risk instead of a flat list of equal-looking ticks. Bundle-wide at the contract-freeze decision point; the §1 assumptions are its first input. If nothing is materially uncertain it still names the single biggest risk — never a blank "none". It makes a genuine review cheap and a lazy one visibly negligent, but cannot *force* the read. The "AI advises" half of **co-specification**. + +**Living document** — an artifact expected to change as the loop learns; never frozen forever (the one exception being a versioned contract, which changes only via a change request). + +**Onboarding** (formerly "on-ramp") — the path a new user walks from install to their first milestone: install → `/add` → describe the goal → the agent runs intake (sizing the request into a milestone the human confirms) → the specification bundle → the self-driving run. The AI-first entry to the method; the human talks to the agent rather than hand-typing `add.py`. + +**Decision point** (formerly "seam") — a place where the flow stops for human judgment: the contract-freeze approval (the one approval), an escalated verify gate, intake confirmation, milestone close. The machine layer keeps the legacy name: the `--json` owner enum `seam`, the decide-digest key `seam`, and the `seam-audit` CI job. + +**The decision arc** — the three engine-sourced lines a gate report opens with at every **decision point**: `goal:` the milestone goal the work serves · `done:` the achievement, the proven progress toward it (the gate reports render this line as `done`) · `plan:` what comes next. What `done` reports adapts per gate (verify: tests + evidence · milestone close: exit-criteria met · intake: the request sized) while the three-part shape stays constant. Rendered first, above the report's summary, so the human confirms with sight of the whole trajectory, not a local snapshot. Engine-sourced like all evidence — goal · done · plan are pulled from `add.py` output, never re-typed. Presentation only: it never adds a gate or changes a `PASS` / `RISK-ACCEPTED` / `HARD-STOP` / freeze outcome. The report it opens is the chat report a person reads at a decision point — distinct from the three Test/Quality/Risk reports a verify gate produces ([11 Governance](./11-governance.md)). See the `add` skill's `report-template.md`. + +**Specification bundle** (formerly "the one-approval front") — §1–§4 of a task (spec · scenarios · contract · failing tests) drafted by the AI as one piece and approved by a person **once**, at the contract freeze. Rejecting any part returns the whole bundle to draft. The single approval it carries is the bundle approval. + +**Retrospective consolidation** (formerly "the fold / fold ritual") — the milestone-close (or on-demand) step where a person gathers `open` lessons learned, confirms each, and the AI writes them append-only into the versioned foundation, bumping `foundation-version:`. The AI never self-approves a consolidation. The machine names keep their names: `fold.md`, the `folded` delta status, and `add.py deltas`. + +**Owner (of a phase)** — who drives a phase, exposed by `add.py … --json` as `human`, `seam`, or `ai` (machine enum values that keep their names; in prose the `seam` value's concept is now the decision point, formerly "seam"). It tells an autonomous harness where it may run (`ai`) and where it must checkpoint to a person (`human`/`seam`), following the who-does-what table (Verify is always `human`). + +**Profile** — the intensity at which the method is run: Express, Standard, or Regulated. + +**Request bucket** — one of the four intake classifications — `new-major`, `sub-milestone`, `task`, or `change-request` — chosen by the tie-break order (the frozen-scope test runs before the size test). A request too vague to size is rejected `ask_human`; one that touches frozen scope, `frozen_scope`; one spanning buckets, `split_required`. + +**`RISK-ACCEPTED`** — a gate outcome meaning work proceeds with a signed waiver (owner, ticket, expiry); allowed for non-security gaps only. + +**Scenario** — a single rule expressed as Given/When/Then; readable by people and checkable by machines; the bridge between spec and tests. + +**Scope drafting (scope-loop)** — the second half of **intake**: once a request is classified `new-major`/`sub-milestone`, turning it into a confirmed, well-formed `MILESTONE.md` (goal · scope · exit criteria · breadth-first tasks) through discussion. Every exit criterion maps to a declared task slug; the AI proposes the draft, the human confirms before anything is created. Lives in the `add` skill's `scope.md`. + +**Spec (`SPEC.md`)** — the plain-language statement of what a feature must do, must reject, and assumes. + +**Cross-cutting concern** (formerly "spine / continuous concern") — a concern that runs through every step rather than being one step: security, testing, observability, cost. + +**Stage** — one pass through the flow at a chosen depth: Prototype, Proof of Concept, MVP, or Production-Ready. + +**Stage graduation** — the orchestration loop that proposes the move to the next **stage** as a human-confirmed roadmap, never a bare flip; the 4th scope level after setup · intake · milestone-loop. The cue is every milestone `done` with the **stage-goal-criteria** all `[x]`; the flow is gather **graduation analytics** → interview *what production means here* → draft ≥1 production milestone → human confirms → `add.py stage production` as the final step. The →production flip is guarded: it refuses with `stage_no_roadmap` (a tally, not a readiness judgment) until ≥1 production milestone exists; `--force` overrides. Lives in the `add` skill's `graduate.md`. + +**Graduation analytics** — the five record-sets `add.py graduation-report` clusters from the whole MVP loop for the graduation interview: open deltas by competency · open RISK-ACCEPTED waivers by expiry · RETRO records · verify residue · observe-loop coverage gaps. It gathers, never judges — there is no readiness verdict, only the records the human reasons from (gather-not-judge). + +**Stage-goal-criteria** — the human-authored `[x]` checklist in `PROJECT.md` that defines "MVP covered" for this project; when every milestone is `done` and these are all checked, `add.py status` prints the graduation cue. Authored by the human (judgment), never inferred by the engine. + +**Baseline approval** (formerly "the lock-down") — the single human gate ending autonomous setup: an explicit yes that freezes the foundation, first scope, and first contract together; runs as `add.py lock --by `. + +**Scope level** (formerly "altitude") — the granularity a decision lives at: intake level (request → versioned scope) · milestone level · setup/foundation level · task level. (A cross-stage decision lives one level out, at the **stage-graduation** loop — which `graduate.md` also numbers as a scope level; see **Stage graduation**.) One ⚠-assumption notation is shared across every scope level. + +**Autonomy level** (formerly "autonomy dial") — the per-task setting (`autonomy: auto | conservative`) choosing who resolves Verify; high-risk scope refuses an unguarded `auto`. + +**Automated quality gate** (formerly "evidence auto-gate") — the Verify resolver under `autonomy: auto`: a run may auto-PASS on complete evidence, recorded as *auto-resolved*; a security finding always escalates (`HARD-STOP`). + +**Change scope** (formerly "touch-boundary") — the hard boundary of a locked run: what it may edit (code, tests-to-green, evidence) and must not (the frozen contract, locked scope, any test weakening). The `` XML prompt tag keeps its name. + +**Non-functional review** (formerly "blind-spot checks") — the deliberate verify-time check of the risks tests rarely catch: concurrency, security, architecture. Security findings always escalate. + +**Failing-first suite** (formerly "red safety net") — the per-feature test suite written before any code and confirmed red for the right reason (a missing implementation, not a broken test); the TDD red phase at ADD step 4. + +**Method rationale** (formerly "trust layer") — the *why* behind every rule: the AIDD book in `.add/docs/`, read on demand via each phase guide's chapter pointer, never auto-loaded. + +**Working state** (formerly "state surface" — one of the two record surfaces) — everything an agent loads every session: the `add` skill (router `SKILL.md` + the active phase) and the lean operational docs — `PROJECT.md`, the active `MILESTONE.md` and `TASK.md`, and `state.json`. Kept small to avoid context rot. Contrast **audit trail**. + +**Stop signal** — the boolean an autonomous harness reads from `add.py … --json` (`stop = owner != "ai"`): true means pause for a person before proceeding. The irreducible stops are the contract freeze and the Verify gate. See **Owner (of a phase)**. + +**Audit trail** (formerly "story surface") — the book (`docs/*`): the whole method, read once by a person to trust ADD, then referenced by a pointer and **never auto-loaded** into agent context. Contrast **working state**. + +**Living documentation** (formerly "survivor layer") — the set of durable artifacts (conventions, glossary, frozen contracts) that outlives any particular code. + +**Trust ladder / autonomy ladder** — the graduated levels of AI autonomy, earned with evidence and verification capacity. + +**Verification capacity / review throughput** — the rate at which a team can confirm AI output is correct; the real ceiling on safe speed. + +--- + +## Optional mapping to formal phase names + +This book uses plain step names. Teams connecting it to a larger formal standard may use these equivalents. The mapping is optional; the plain flow is complete on its own. + +| Plain step (this book) | Formal phase name | +|------------------------|-------------------| +| Project setup | Foundation | +| Specify | Domain Discovery + Spec Definition | +| (design portion) | UX-Driven Design | +| Scenarios | Behavior specification (Given/When/Then) | +| Contract | Contract Freeze | +| Tests | Test-Driven Verification | +| Build | AI-Driven Development (the engine) | +| Verify | the review gate within the build | +| Observe (loop) | Operate and Learn | + +The formal standard also names the *foundation* and *design* work as full phases in their own right; this book merges them into project setup and the Specify step (and the Prototype stage) to keep the flow to six memorable steps. diff --git a/.add/docs/appendix-d-worked-example.md b/.add/docs/appendix-d-worked-example.md new file mode 100644 index 000000000..d55528e33 --- /dev/null +++ b/.add/docs/appendix-d-worked-example.md @@ -0,0 +1,152 @@ +# Appendix D · The worked example, end to end + +[← Appendix C Glossary](./appendix-c-glossary.md) · [Contents](./README.md) · Next: [Appendix E Checklists →](./appendix-e-checklists.md) + +The running example, assembled in one place so you can see a complete pass through the flow without flipping between chapters. The feature: **transfer money between a user's own accounts.** + +--- + +## Step 1 — Specify → `SPEC.md` + +``` +Feature: Transfer money between my own accounts +Framings weighed: synchronous single-currency transfer (chosen) · queued transfer · multi-currency with FX +Must: + - move an amount from one of my accounts to another of mine + - amount > 0 + - source and destination are different accounts + - source has enough balance +After: + - source balance -= amount, destination balance += amount +Reject: + - amount <= 0 -> "amount_invalid" + - source == destination -> "same_account" + - balance < amount -> "insufficient_funds" + - account not mine -> "forbidden" +Assumptions — lowest-confidence first: + ⚠ same currency only (no FX) in v1 — lowest confidence because the ticket never said; if wrong: the amount/rounding model changes and this contract is wrong + - [x] no daily limit in v1 — confirmed: out of scope for v1 +``` + +The product owner read the flagged assumption first — the single-currency choice, the one most likely to be wrong and most expensive if it were — and confirmed it: v1 is single-currency with no daily limit. + +## Step 2 — Scenarios → `features/transfer.feature` + +``` +Scenario: successful transfer + Given A has 100 and B has 0, both mine + When I transfer 30 from A to B + Then A has 70 and B has 30 + +Scenario: amount must be positive + Given A has 100, mine + When I transfer 0 from A to B + Then it is rejected "amount_invalid" + And no balance changes + +Scenario: same account + Given A has 100, mine + When I transfer 10 from A to A + Then it is rejected "same_account" + And no balance changes + +Scenario: insufficient funds + Given A has 20, mine + When I transfer 50 from A to B + Then it is rejected "insufficient_funds" + And no balance changes + +Scenario: not my account + Given account C is not mine + When I transfer 10 from C to B + Then it is rejected "forbidden" +``` + +Five scenarios for four rejections plus the happy path — every rule from the spec is covered. + +## Step 3 — Contract → `contracts/transfer.md` + +``` +POST /transfers body: { fromAccountId, toAccountId, amount } + 200 -> { transferId, fromBalance, toBalance } + 400 -> { error: "amount_invalid" | "same_account" | "insufficient_funds" } + 403 -> { error: "forbidden" } +Schema: accounts.balance (read + write, must be transactional) +Status: FROZEN @ v1 +``` + +Frozen at v1. The schema note flags the atomicity requirement the verification step will check. + +## Step 4 — Tests → `tests/transfer_test.py` (run first; all fail) + +```python +def test_successful_transfer(): + a = account(balance=100, owner=me); b = account(balance=0, owner=me) + r = transfer(a.id, b.id, 30) + assert r.status == 200 + assert a.balance == 70 and b.balance == 30 + +def test_amount_must_be_positive(): + a = account(balance=100, owner=me); b = account(balance=0, owner=me) + r = transfer(a.id, b.id, 0) + assert r.status == 400 and r.error == "amount_invalid" + assert a.balance == 100 and b.balance == 0 + +def test_same_account(): + a = account(balance=100, owner=me) + r = transfer(a.id, a.id, 10) + assert r.status == 400 and r.error == "same_account" + assert a.balance == 100 + +def test_insufficient_funds(): + a = account(balance=20, owner=me); b = account(balance=0, owner=me) + r = transfer(a.id, b.id, 50) + assert r.status == 400 and r.error == "insufficient_funds" + assert a.balance == 20 + +def test_not_my_account(): + c = account(balance=100, owner=someone_else); b = account(balance=0, owner=me) + r = transfer(c.id, b.id, 10) + assert r.status == 403 and r.error == "forbidden" +``` + +Run now, with no implementation: all five fail. That is the honest baseline. + +## Step 5 — Build → the prompt given to the AI + +``` +Read SPEC.md, contracts/transfer.md, and tests/transfer_test.py. +Implement POST /transfers so that EVERY test passes. +Constraints: + - Do NOT change any test. + - Do NOT change the contract. + - Make the balance update atomic: debit and credit in a single transaction, + and re-check the balance inside the transaction. + - Stop and ask if any requirement is unclear — do not guess. + - Use only packages in dependencies.allowlist. +Report which tests pass and exactly what you changed. +``` + +The AI implements, runs the suite, iterates, and reports all five green, listing the files it changed. + +## Step 6 — Verify → the human checks + +- **Evidence:** all five tests pass; coverage held; no test or contract was altered. ✓ +- **Concurrency (the key check):** two simultaneous transfers from account A must not both pass the balance check and overdraw it. The reviewer confirms the balance re-check happens *inside* the transaction and that the row is locked for the update — so a race cannot double-spend. ✓ +- **Security:** no hardcoded secrets; inputs validated; no new dependency added. ✓ +- **Architecture:** the change respects the layering in `CONVENTIONS.md`. ✓ +- **Outcome recorded:** `PASS`, reviewed by the senior engineer. + +## The loop — observe + +Released behind a feature flag to 5% of users. Monitored: + +- transfer error rate (target: well under 0.1% of attempts); +- the rate of each rejection — a spike in `insufficient_funds` would suggest a UX problem (users not seeing their balance) rather than a code defect; +- latency of the atomic update under load. + +A week later, telemetry shows an unexpectedly high `forbidden` rate. The `6_observe` prompt clusters it: users are trying to transfer *into* a shared account they can see but do not own. That observation becomes a `SPEC.md` delta — "support transfers into accounts I am authorized on, not only accounts I own" — and the flow returns to Step 1 for the next cycle. + +--- + +This is the whole method in one feature: four artifacts written in order, an AI build bounded by them, a verification grounded in evidence plus the one check tests miss, and a loop that turns production reality into the next specification. diff --git a/.add/docs/appendix-e-checklists.md b/.add/docs/appendix-e-checklists.md new file mode 100644 index 000000000..ce753f214 --- /dev/null +++ b/.add/docs/appendix-e-checklists.md @@ -0,0 +1,80 @@ +# Appendix E · Checklists + +[← Appendix D Worked example](./appendix-d-worked-example.md) · [Contents](./README.md) · Next: [Appendix F Requirements matrix →](./appendix-f-requirements-matrix.md) + +Every exit check in the book, collected for quick use. Print this page. + +--- + +## Setup (once per project) + +- [ ] Pipeline runs and is green on the empty skeleton. +- [ ] AI model pinned in `MODEL_REGISTRY.md`. +- [ ] Dependency allow-list exists; pipeline fails on anything outside it. +- [ ] `playbook/` contains the six prompts. + +## Step 1 — Specify + +- [ ] Every required behavior stated explicitly. +- [ ] Every rejection has a named error code. +- [ ] Success state-change described. +- [ ] Assumptions ranked lowest-confidence first; the 1–2 most-likely-wrong ⚠-flagged with why + cost (or an honest "none material" that still names the single biggest risk). + +## Step 2 — Scenarios + +- [ ] Every "Must" rule has a scenario. +- [ ] Every "Reject" rule has a scenario. +- [ ] Each result is a specific, observable fact. +- [ ] Rejections assert what must stay unchanged. + +## Step 3 — Contract + +- [ ] Contract versioned and `FROZEN`. +- [ ] Contract tests pass against the mock. +- [ ] Names match the glossary. +- [ ] Every spec rejection has a contracted response. + +## Step 4 — Tests + +- [ ] One test per scenario. +- [ ] Suite runs in the pipeline and is red for the right reason. +- [ ] Tests assert behavior, not internals. +- [ ] Coverage target recorded. + +## Step 5 — Build + +- [ ] All tests pass. +- [ ] Coverage did not decrease. +- [ ] No test or contract modified by the AI. +- [ ] No package outside the allow-list added. +- [ ] Change is small enough to review in full. + +## Step 6 — Verify + +- [ ] All tests pass (the evidence). +- [ ] Concurrency/timing of the risky operation is safe. +- [ ] No exposed secrets, injection, or unexpected dependencies. +- [ ] Layering and dependencies follow `CONVENTIONS.md`. +- [ ] A person reviewed and approved. +- [ ] Outcome recorded (`PASS` / `RISK-ACCEPTED` / `HARD-STOP`). + +## The loop + +- [ ] Released behind a flag or gradual rollout. +- [ ] Scenarios reused as production monitors. +- [ ] Learnings written back as a `SPEC.md` delta. + +--- + +## Master shippable checklist + +A feature is shippable only when all are true: + +- [ ] Spec complete: behavior stated, rejections named, assumptions ranked lowest-confidence first with the biggest risk flagged. +- [ ] Every rule has a scenario. +- [ ] Contract frozen; contract tests green. +- [ ] A test per scenario; suite was red before the build. +- [ ] All tests green; coverage held; tests and contract untouched by the AI. +- [ ] Concurrency, security, and architecture checked by a person. +- [ ] Gate outcome recorded with an accountable owner. +- [ ] Released behind a flag, with monitors in place. diff --git a/.add/docs/appendix-f-requirements-matrix.md b/.add/docs/appendix-f-requirements-matrix.md new file mode 100644 index 000000000..f8d92028f --- /dev/null +++ b/.add/docs/appendix-f-requirements-matrix.md @@ -0,0 +1,171 @@ +# Appendix F · Document requirements matrix (Project → Milestone → Task) + +[← Appendix E Checklists](./appendix-e-checklists.md) · [Contents](./README.md) + +This appendix maps every AIDD document to a three-level project hierarchy, so that at any level a team can answer three questions: **which documents must exist, who owns them, and what proves the level is complete.** It is the traceability backbone of the method — read it alongside the stage-depth matrix in [10 Setup and stages](./10-setup-and-stages.md), which covers *step* depth; this appendix covers *document* requirements. + +--- + +## The three levels + +| Level | What it is | AIDD meaning | Spans | +|-------|-----------|--------------|-------| +| **Project** | the whole product or engagement | the living documentation — documents created once and kept for the life of the product | all milestones | +| **Milestone** | a stage or release | one pass of the flow at a chosen depth: Prototype, POC, MVP, or Production-Ready; groups many tasks | many tasks | +| **Task** | one feature through the flow | a single pass of Specify → … → Verify → Observe; the smallest unit with its own gate records | the seven steps | + +A **project** sets up the living documentation once. A **milestone** is a depth-bounded goal that groups tasks and has its own entry and exit document gates. A **task** is one feature, and it produces the per-feature artifacts. + +## How the hierarchy decomposes + +```mermaid +flowchart TD + P["PROJECT — the product
PROJECT.md (foundation) · CONVENTIONS · GLOSSARY · MODEL_REGISTRY · allowlist · playbook"] + P --> M1["MILESTONE · Prototype"] + P --> M2["MILESTONE · POC"] + P --> M3["MILESTONE · MVP"] + P --> M4["MILESTONE · Production-Ready"] + M3 --> T1["TASK · Transfer between accounts
SPEC · feature · contract · tests · code · gate records"] + M3 --> T2["TASK · View balance"] + M3 --> T3["TASK · Transaction history"] + classDef p fill:#F1EFE8,stroke:#5F5E5A,color:#2C2C2A; + classDef m fill:#FAEEDA,stroke:#BA7517,color:#633806; + classDef t fill:#E6F1FB,stroke:#185FA5,color:#042C53; + class P p; class M1,M2,M3,M4 m; class T1,T2,T3 t; +``` + +--- + +## Matrix 1 — Documents by level (ownership and lifespan) + +Which document lives at which level, who is accountable for it, and how long it lasts. + +| Document | Level | Created | Lifespan | Accountable owner | +|----------|:-----:|---------|----------|-------------------| +| `PROJECT.md` (foundation: domain · spec · UI/UX) | Project | setup, grows | whole project | Product / Architect | +| `CONVENTIONS.md` | Project | setup | whole project | Architect / Lead | +| `GLOSSARY.md` | Project | setup, grows | whole project | Product / Domain | +| `MODEL_REGISTRY.md` | Project | setup | whole project | Architect / Lead | +| `dependencies.allowlist` | Project | setup | whole project | Security | +| `playbook/*.md` (prompts) | Project | setup, versioned | whole project | Eng Lead | +| Stage plan / roadmap | Milestone | per milestone | the milestone | EM / Delivery | +| Milestone exit report | Milestone | milestone end | the milestone | EM / Delivery | +| `SLO.md` (objectives) | Milestone (MVP+) | from MVP | from MVP onward | DevOps / SRE | +| `SPEC.md` | Task | per feature | living | Product / Domain | +| `features/*.feature` | Task | per feature | living | QA / Test | +| `contracts/*.md` | Task → **Project** | per feature, then frozen | living doc (promoted to project) | Architect / Lead | +| `tests/*` | Task | per feature | living | QA / Engineer | +| Source code | Task | per feature | **disposable** | Engineer | +| Gate outcome records | Task | per step | kept for audit | the reviewer | + +> Note the one promotion: a **contract** is authored at task level but, once frozen, becomes part of the project's living documentation — other tasks depend on it. That promotion is why a contract change is a project-level change request, not a task-local edit. + +--- + +## Matrix 2 — Documents required by milestone + +Which documents must exist, and at what depth, to **exit** each milestone. Depth: **Deep** · **Core** · **Light** · **—** (not required). + +| Document | Prototype | POC | MVP | Production-Ready | +|----------|:---------:|:---:|:---:|:----------------:| +| `CONVENTIONS.md` | Light | Core | Required | Required | +| `GLOSSARY.md` | seed | Core | Required | Required | +| `MODEL_REGISTRY.md` | Required | Required | Required | Required | +| `dependencies.allowlist` | optional | Required | Required | Required | +| `playbook/*.md` | Required | Required | Required | Required | +| `SPEC.md` | Light | Deep (risky slice) | Required (full) | Required (full) | +| Design: flows + screen states | **Deep** | Light | Core | Deep | +| `features/*.feature` | — | Core | Required | Exhaustive | +| `contracts/*.md` (frozen) | — | Core (risky slice) | Required (frozen) | Required (versioned) | +| `tests/*` | — | Core | Core | Full coverage | +| `SLO.md` | — | — | Light | Required | +| Gate outcome records | — | Core | Required | Required (all `HARD-STOP`) | +| Operate / observe report | — | — | Light | Required | +| Milestone exit report | Light | Core | Required | Required | + +**Reading it:** a Prototype exits on a deep design and little else; a POC adds a deep spec, core scenarios/contract/tests on the risky slice; an MVP requires the full per-feature document set plus light operations; Production requires everything at full depth with operations and audit-grade gate records. + +--- + +## Matrix 3 — Documents required per task (the seven steps) + +Every task, regardless of milestone, produces this artifact chain. The depth varies by milestone (Matrix 2); the *sequence and exit gate* do not. + +| Step | Required document | Exit gate (the proof) | Detail | +|------|-------------------|------------------------|--------| +| 1 Specify | `SPEC.md` | rules + named rejections, assumptions ranked lowest-confidence first (biggest risk ⚠-flagged) | [03](./03-step-1-specify.md) | +| 2 Scenarios | `features/.feature` | one scenario per rule | [04](./04-step-2-scenarios.md) | +| 3 Contract | `contracts/.md` | frozen + contract tests green | [05](./05-step-3-contract.md) | +| 4 Tests | `tests/_*` | one test per scenario, red first | [06](./06-step-4-tests.md) | +| 5 Build | source code + evidence bundle | all tests green, nothing weakened | [07](./07-step-5-build.md) | +| 6 Verify | gate outcome record | `PASS` / `RISK-ACCEPTED` / `HARD-STOP` (auto-resolved on evidence under `autonomy: auto`; security always escalates) | [08](./08-step-6-verify.md) | +| 7 Observe | `TASK.md` §7 OBSERVE block | released behind a flag; scenario-monitors live; spec delta + lessons learned captured | [09](./09-the-loop.md) | + +A task is **done** when the build's documents exist and the Verify record reads `PASS` (or a signed `RISK-ACCEPTED`); the seventh step — **Observe** (§7) — then runs in production and feeds the next loop's Specify. See the master shippable checklist in [Appendix E](./appendix-e-checklists.md). + +--- + +## Matrix 4 — Executable proofs (the claims the engine enforces) + +The rows above are the method's *promises*. A promise a tool quietly breaks is worse than none — so the `add` engine ships a proof-harness: each invariant below is pinned by an automated test that fails loudly if the **Story** (this book) and the **State** (the engine) drift apart. This table is the coverage *so far*, not a completeness claim — but the minimalism-and-coverage audit has now run once over Matrices 1–3 (see **Sweep findings** below); what it could cheaply prove, it added; what it deliberately left unenforced, it recorded. + +| Claim (where it lives) | The engine enforces | Proof test | +|------------------------|---------------------|------------| +| No silent skips (principle 7) · "done only when Verify reads `PASS`" (Matrix 3) | `gate PASS` is **refused** unless the task has reached `verify` | `test_gate_pass_refused_before_verify` | +| A passed task is genuinely done | `gate PASS` at `verify` advances to `done` | `test_gate_pass_at_verify_reaches_done` | +| Deliberate ≠ silent | the explicit `phase` command is a logged escape hatch the guardrail does not block | `test_phase_override_escape_hatch` | +| "A security finding is ALWAYS `HARD-STOP`" | `HARD-STOP` is recordable from any phase and never forces `done` | `test_hardstop_recordable_mid_build` | +| "done … or a signed `RISK-ACCEPTED`" (Matrix 3) | `gate RISK-ACCEPTED` at `verify` advances to `done` (same guard as `PASS`) | `test_risk_accepted_complete_reaches_done` | +| A waived task **can complete its milestone** (the point of the waiver) | the completeness predicate counts a signed `RISK-ACCEPTED` as done, so `milestone-done` / `ready` / `check` / `archive` accept it — it does not silently block | `test_milestone_done_accepts_a_waived_task` · `test_check_tolerates_a_recorded_waiver` | +| A waiver is **signed** (owner · ticket · expiry) | `gate RISK-ACCEPTED` is refused without all three; they are stored in state | `test_risk_accepted_requires_waiver` · `test_risk_accepted_partial_waiver_refused` | +| A waiver can **expire** — a lapsed one is caught, not trusted forever | `check` **FAILS** a `RISK-ACCEPTED` task whose stored `expires` is before today; fail-closed on a missing/unparseable date (`waiver_expired`) | `test_check_flags_expired_waiver` · `test_check_passes_unexpired_waiver` · `test_check_failclosed_on_unparseable_expires` | +| **The Story is never auto-loaded** (principle 9, the *Minimal* pillar) | **no** command reads a `docs/` chapter at runtime — and the spy runs *every* subcommand the parser exposes, so "no command" is universal, not a subset; a project with **no** `docs/` runs the whole lifecycle | `test_full_lifecycle_runs_with_no_story` · `test_no_command_reads_a_docs_chapter` · `test_every_subcommand_is_covered` | +| The book's gate outcomes are the engine's | `PASS` · `RISK-ACCEPTED` · `HARD-STOP` exist in both prose and `GATES` | `test_book_gate_outcomes_match_engine` | + +The tests are the source of truth; this table is their index. If a row here is ever unproven, that is a gap in the method, not a detail — the proof-harness exists to make such gaps fail loudly. (Tests: `add-method/tooling/test_proof_harness.py`, `test_waiver.py`.) + +**Now closed:** an earlier version of this table flagged a `RISK-ACCEPTED` gap — the engine advanced only `PASS` to `done`, so a waived task could not complete its milestone, and the waiver fields were uncaptured. The `RISK-ACCEPTED` rows above close it: a signed waiver (owner · ticket · expiry) now completes a verify-phase task and is stored in state for a later `check` to expire. Closing it took *two* edits, not one — advancing the gate to `done` was necessary but not sufficient, because the shared completeness predicate (`milestone-done` / `ready` / `check` / `archive` all read it) still counted only `PASS`; a waived task reached `done` yet silently blocked its milestone until that predicate was taught to count a signed `RISK-ACCEPTED` too. The end-to-end row above is what catches that class of half-fix — proving the *task* completes is not proving the *milestone* can. The pattern that found it — book-claim → engine-enforces → named test — is the standing way to audit the remaining rows. + +**Sweep findings (minimalism-and-coverage audit, v2):** the audit walked Matrices 1–3 for claims the engine *could* enforce but did not yet prove. + +- **Proved and added** (rows above): the *Minimal* pillar's headline — "the Story is never auto-loaded" (principle 9) — was written but unproven; it is now pinned behaviorally (the engine runs the whole lifecycle with no `docs/` present, and a read-spy over *every* subcommand confirms none reads a chapter at runtime). And waiver **expiry**, which Matrix 4 already promised the state captured "for a later `check` to expire," is now enforced. +- **Recorded, deliberately *not* enforced:** Matrix 3 says a task is done only when "all six documents exist." The engine checks that `TASK.md` *exists* and that its phase marker matches state — it does **not** parse the file to confirm each of the seven sections is filled. Teaching it to grade section completeness would push the engine toward reading and judging the Story it is supposed to keep off the runtime path — an anti-minimal move. The reviewer owns section completeness at the Verify gate (the human-led half of the method); the engine owns the cheap structural invariants. This is a chosen boundary, not an oversight. +- **Lean check:** the audit confirmed `state.json` carries no redundant per-task fields — `title`, `phase`, `gate`, `milestone`, `depends_on`, the two timestamps, and a `waiver` only once one is signed; nothing to trim. A clean bill is a finding too. + +--- + +## Worked example — the hierarchy filled in + +- **Project:** *Mobile Banking App.* Living documentation: `CONVENTIONS.md`, `GLOSSARY.md` (defines *account*, *balance*, *transfer*), `MODEL_REGISTRY.md`, `dependencies.allowlist`, `playbook/`. +- **Milestone:** *MVP — core money movement.* Exit requires the full per-feature document set for each task below, plus a light `SLO.md` and a milestone exit report. + - **Task:** *Transfer between own accounts* → `SPEC.md`, `features/transfer.feature`, `contracts/transfer.md` (frozen at v1), `tests/transfer_test.py`, code, and a `PASS` gate record. (The full set is in [Appendix D](./appendix-d-worked-example.md).) + - **Task:** *View balance* → its own SPEC, feature, contract, tests, code, record. + - **Task:** *Transaction history* → its own set. + +When all three tasks read `PASS` and the milestone documents exist, the MVP milestone exits — and the frozen `transfer` contract is now a project-level living-documentation artifact the next milestone builds on. + +--- + +## Traceability chain + +The hierarchy gives a clean line of evidence from a business goal down to a passing test — which is what makes an AIDD project auditable: + +``` +PROJECT goal "let customers move their own money safely" + └─ MILESTONE (MVP) "core money movement" + └─ TASK "transfer between own accounts" + └─ SPEC rule "source must have enough balance" + └─ SCENARIO "insufficient funds -> rejected, no change" + └─ TEST test_insufficient_funds (was red, now green) + └─ VERIFY record PASS (atomicity checked) +``` + +Every level points down to the evidence beneath it and up to the goal above it. To audit any claim — "we handle insufficient funds correctly" — you follow the chain to a specific test and a specific gate record. Nothing rests on assertion. + +--- + +*This matrix is the requirements view of the method. The flow ([Part II](./02-the-flow.md)) tells you the order; the stages ([10](./10-setup-and-stages.md)) tell you the depth; this appendix tells you, at each level of the project, exactly which documents must exist and who owns them.* + +--- + +*End of book. AIDD is one repeatable loop — Specify → Scenarios → Contract → Tests → Build → Verify → observe, then repeat. People own direction and verification; the AI owns the build; the artifacts are the asset and the code is disposable.* diff --git a/.add/docs/appendix-g-references.md b/.add/docs/appendix-g-references.md new file mode 100644 index 000000000..b32909e68 --- /dev/null +++ b/.add/docs/appendix-g-references.md @@ -0,0 +1,106 @@ +# Appendix G — References & Lineage + +ADD did not appear from nowhere. It sits at the meeting point of three currents: +the **recursive self-improvement** thesis (AI that helps build the next AI), the +**spec-driven development** movement (the specification, not the code, is the +source of truth), and a decade of **agentic + tests-first** research showing that +a generate→check→refine loop, constrained by executable tests, turns fluent model +output into trustworthy software. This appendix is the curated, verified grounding +for that lineage — every source below is reachable and annotated with a `↔ ADD:` +line saying exactly how it relates to the method. + +**The frame — "closing the loop."** Anthropic's recursive-self-improvement picture +runs from autonomous agents delegating to workers *today* toward a future where +Claude improves Claude. ADD is a deliberately **human-gated, evidence-trusted** +instance of that loop: the AI drives spec→build→verify→observe, but a human owns the +frozen contract and the verify gate, and trust comes from passing tests and +re-resolved evidence — never from a plausible-looking diff. The sources here are +the shoulders that posture stands on. + +The four sections below are the four currents. The comparison table places ADD next +to its two closest peers — GitHub's **spec-kit** and **GSD (Get Shit Done)** — and +names where ADD diverges. Read "How to cite" first; the rest of the book cites into +the keys defined here. + +## How to cite + +The book uses one inline citation form — **author-year** — and every entry's lead +`(Author Year)` *is* its cite-key. Resolve any inline `[…]` to the matching entry below. + +| Authors | Inline form | Example | +|---|---|---| +| one author | `[Surname Year]` | `[Schmidhuber 2003]` | +| two authors | `[Surname & Surname Year]` | `[Mathews & Nagappan 2024]` | +| three or more | `[Surname et al. Year]` | `[Zelikman et al. 2023]` | +| an organisation | `[Org Year]` | `[Anthropic 2026a]` · `[GitHub 2025]` | +| several at once | joined by `; ` | `[Schmidhuber 2003; Zelikman et al. 2023]` | +| same author, same year | add a `Year`-letter suffix | `[Anthropic 2025a]` / `[Anthropic 2025b]` | + +The 3+-author rule becomes **et al.**; an organisation stands in as the author +when no individual is credited; and when two org-authored sources collide on a year +(several Anthropic 2025/2026 items do, below) a trailing letter disambiguates them. +There is exactly one entry per cite-key. + +## spec-kit ↔ ADD (and GSD) + +ADD shares the spec-first DNA of GitHub's **spec-kit** and the Claude-Code, +context-rot-fighting niche of **GSD**. The phase models line up closely: + +| ADD phase | spec-kit command | GSD phase | +|---|---|---| +| foundation · principles | `/speckit.constitution` → `constitution.md` | (project setup / `CLAUDE.md`-level) | +| §1 specify (what / why) | `/speckit.specify` → `spec.md` | **discuss** — capture decisions before planning | +| §3 contract (how, frozen) | `/speckit.plan` → `plan.md`, `contracts/` | **plan** — research, decompose, fit fresh context | +| milestone tasks / waves | `/speckit.tasks` → `tasks.md` | (phases → parallel waves) | +| §5 build | `/speckit.implement` | **execute** — parallel waves, fresh 200k-token context each | +| §6 verify | `/speckit.analyze` + `/speckit.checklist` | **verify** — walk what was built, fix before declaring done | + +**Where ADD diverges.** spec-kit stops at `implement`; GSD ends at verify (GSD Core +adds a fifth *ship* phase). ADD closes the loop past both by adding three things +neither has as a first-class gate: a **failing-tests-first** gate (§4 — no build +starts until the tests are red for the right reason), an **observe→`fold`** +self-improvement step (§7 — confirmed learnings consolidate into a versioned foundation), +and an engine-tracked **dynamic goal-loop** that will hold a milestone open and +reopen tasks until its exit criteria are met. ADD also deliberately targets **less +doc-time than GSD** — a lean foundation and one human approval per task, rather than +a document per phase. The shared lineage is real; the tests-first gate, the `fold`, +and the goal-loop are ADD's contribution. + +## 1. Recursive self-improvement + +- **When AI builds itself** (Favaro & Clark 2026) — https://www.anthropic.com/institute/recursive-self-improvement — essay. The RSI thesis: by 2026 >80% of code merged at Anthropic was Claude-authored and the 50%-task time-horizon keeps doubling; recursive self-improvement would shift humans from builders to validators. ↔ ADD: the seed source — ADD is the human-gated, evidence-trusted way to run a spec→build→verify→observe loop while the human stays the validator. +- **Automated Alignment Researchers** (Anthropic 2026a) — https://www.anthropic.com/research/automated-alignment-researchers — research. Nine parallel Claude agents recovered ~97% of the human-expert gap on an alignment task in 5 days versus 7 for the human team. ↔ ADD: the strongest evidence the recursive loop is not speculative — parallel agents under review are exactly ADD's wave-plus-verify shape. +- **Machines of Loving Grace** (Amodei 2024) — https://www.darioamodei.com/essay/machines-of-loving-grace — essay. A "country of geniuses in a datacenter," argued with a measured, bounded position on recursive self-improvement. ↔ ADD: the intent framing behind milestoning — bound the loop with human direction rather than let it run open. +- **Gödel Machines: Self-Referential Universal Problem Solvers** (Schmidhuber 2003) — https://arxiv.org/abs/cs/0309048 — paper. A provably-optimal self-modifying agent that rewrites itself only when it can prove the rewrite helps. ↔ ADD: the mathematical anchor of the lineage — and a precedent for "only change on proof," which ADD enforces socially via the never-weaken-a-test rule. +- **STOP: Self-Taught Optimizer** (Zelikman et al. 2023) — https://arxiv.org/abs/2310.02304 — paper. A scaffolding program recursively improves the code that improves code. ↔ ADD: the algorithmic kin of the `fold` step — consolidate confirmed learnings back into the method that produced them. +- **Self-Refine: Iterative Refinement with Self-Feedback** (Madaan et al. 2023) — https://arxiv.org/abs/2303.17651 — paper. Generate→critique→refine with the same model lifts quality ~20% with no extra training. ↔ ADD: the micro-loop inside build→verify — produce, check against the contract, refine. +- **Self-Rewarding Language Models** (Yuan et al. 2024) — https://arxiv.org/abs/2401.10020 — paper. A model acts as its own reward judge to improve across iterations. ↔ ADD: the risk ADD answers — a self-judging loop needs an external gate; ADD makes tests and a human the reward signal, not the model's own opinion. +- **Reflexion: Language Agents with Verbal Reinforcement Learning** (Shinn et al. 2023) — https://arxiv.org/abs/2303.11366 — paper. Agents keep verbal reflections in episodic memory and retry, reaching 91% on HumanEval. ↔ ADD: the principle behind "reopen the task if criteria are unmet" — a failed check becomes feedback for the next attempt, not a dead end. +- **Voyager: An Open-Ended Embodied Agent with LLMs** (Wang et al. 2023) — https://arxiv.org/abs/2305.16291 — paper. An auto-curriculum agent that grows a reusable skill library over time. ↔ ADD: the growing foundation — each milestone's consolidated deltas are ADD's accumulating skill library. +- **AlphaEvolve: A Coding Agent for Scientific and Algorithmic Discovery** (Novikov et al. 2025) — https://arxiv.org/abs/2506.13131 — paper. An evolutionary coding agent that beat a long-standing matrix-multiplication record and shipped a production scheduler improvement. ↔ ADD: the end-state evidence — a generate-and-verify loop can exceed human baselines when every candidate is checked. + +## 2. Autonomous & agentic workflows + +- **Building Effective Agents** (Schluntz & Zhang 2024) — https://www.anthropic.com/research/building-effective-agents — blog. The canonical taxonomy: prompt-chaining, routing, orchestrator-workers, and the evaluator-optimizer loop. ↔ ADD: the architecture cite — evaluator-optimizer is build→verify→refine; orchestrator-workers is ADD's wave parallelism. +- **Enabling Claude Code to work more autonomously** (Anthropic 2025a) — https://www.anthropic.com/news/enabling-claude-code-to-work-more-autonomously — news. Checkpoints, subagents, hooks, background tasks, and `/rewind` rollback. ↔ ADD: checkpoint/rewind is the rollback strategy behind phase gates; hooks are where the engine enforces them. +- **How we built our multi-agent research system** (Anthropic 2025b) — https://www.anthropic.com/engineering/multi-agent-research-system — blog. An Opus lead orchestrating Sonnet subagents, with an LLM acting as judge, lifting task performance ~90%. ↔ ADD: the lead-plus-subagents-plus-judge pattern is exactly ADD's wave execution under a verify gate. +- **ReAct: Synergizing Reasoning and Acting in Language Models** (Yao et al. 2022) — https://arxiv.org/abs/2210.03629 — paper. Interleaving think→act→observe turns a model into an agent. ↔ ADD: the base loop every ADD phase runs on. +- **Toolformer: Language Models Can Teach Themselves to Use Tools** (Schick et al. 2023) — https://arxiv.org/abs/2302.04761 — paper. Self-supervised learning of when and how to call external tools. ↔ ADD: the capability that lets an agent run its own tests, linters, and builds — the evidence ADD trusts. +- **SWE-agent: Agent–Computer Interfaces Enable Automated Software Engineering** (Yang et al. 2024) — https://arxiv.org/abs/2405.15793 — paper. A designed agent–computer interface materially improves autonomous issue resolution. ↔ ADD: the structured agent↔environment contract — ADD's `add.py` engine is that interface for the method. +- **The AI Scientist: Towards Fully Automated Open-Ended Scientific Discovery** (Lu et al. 2024) — https://arxiv.org/abs/2408.06292 — paper. A full idea→experiment→write→review research loop at ~$15 per paper. ↔ ADD: the research analog of ADD's loop — and a reminder that an automated reviewer is the weak link a human gate protects. + +## 3. Spec-driven development & spec-kit + +- **GitHub Spec Kit** (GitHub 2025) — https://github.com/github/spec-kit — repo. The reference SDD toolkit: the phase model is `constitution` → `specify` → `plan` → `tasks` → `implement`, with the spec as the executable source of truth. ↔ ADD: the closest spec-first sibling — ADD's specify and contract phases map onto specify and plan; see the comparison table for the divergence. +- **Spec-driven development with AI: get started with a new open-source toolkit** (Delimarsky 2025) — https://github.blog/ai-and-ml/generative-ai/spec-driven-development-with-ai-get-started-with-a-new-open-source-toolkit/ — blog. The spec-kit launch post; frames `tasks` as "TDD for your AI agent." ↔ ADD: independent articulation of why decomposing a spec into checkable units beats one big prompt. +- **Spec-driven development: using Markdown as a programming language when building with AI** (Vesely 2025) — https://github.blog/ai-and-ml/generative-ai/spec-driven-development-using-markdown-as-a-programming-language-when-building-with-ai/ — blog. Spec-as-source, with context-rot named as the failure SDD exists to solve. ↔ ADD: the rationale for the frozen contract — a stable written spec is what survives when the model's context degrades. +- **Get Shit Done (GSD)** (GSD 2025) — https://github.com/open-gsd/gsd-core — repo. A meta-prompting, context-engineering, spec-driven system for Claude Code; its `discuss` → `plan` → `execute` → `verify` cycle runs each phase in a fresh subagent context to fight context-rot (originally `gsd-build/get-shit-done`, now continued as GSD Core). ↔ ADD: ADD's closest peer — same Claude-Code, context-rot niche; ADD diverges with the tests-first gate, the observe→`fold` step, and the dynamic goal-loop, and aims for less doc-time than GSD. +- **Beyond Vibe Coding: Amazon Introduces Kiro, the Spec-Driven Agentic IDE** (InfoQ 2025) — https://www.infoq.com/news/2025/08/aws-kiro-spec-driven-agent/ — blog. Kiro structures work as requirements→design→tasks with execution hooks. ↔ ADD: cross-vendor confirmation that spec-first is converging across the industry, not a single-tool idea. +- **Spec-Driven Development: From Code to Contract in the Age of AI Coding Assistants** (Piskala 2026) — https://arxiv.org/abs/2602.00180 — paper. A taxonomy of SDD rigor — Spec-First, Spec-Anchored, Spec-as-Source — reporting human-refined specs can cut LLM code errors substantially, with BDD as SDD's ancestor. ↔ ADD: places ADD as "Spec-Anchored" and gives the academic vocabulary for the contract-freeze decision. + +## 4. Tests-first & verification + +- **Test-Driven Development for Code Generation** (Mathews & Nagappan 2024) — https://arxiv.org/abs/2402.13521 — paper. Supplying tests alongside the prompt measurably lifts pass rates on MBPP and HumanEval. ↔ ADD: the empirical backbone of the failing-tests-first gate — tests as the constraint that makes generation verifiable. +- **SWE-bench: Can Language Models Resolve Real-World GitHub Issues?** (Jimenez et al. 2023) — https://arxiv.org/abs/2310.06770 — paper. 2,294 real issues judged by whether the project's own tests pass; <2% solved at release. ↔ ADD: the yardstick that proves the point — "done" means the tests pass, which is exactly how ADD gates a feature. +- **Our framework for developing safe and trustworthy agents** (Anthropic 2025c) — https://www.anthropic.com/news/our-framework-for-developing-safe-and-trustworthy-agents — news. Five principles: human control, transparency, alignment, privacy, and security. ↔ ADD: the frozen-contract gate and never-weaken-a-test rule are human control and transparency made concrete; the security HARD-STOP is the security principle. +- **Responsible Scaling Policy v3.0** (Anthropic 2026b) — https://www.anthropic.com/news/responsible-scaling-policy-v3 — policy. The AI Safety Level framework; ASL-3 governs autonomous R&D capability. ↔ ADD: the governance ceiling that makes ADD's discipline necessary — as the loop gets more capable, the gates and the human-owned verify matter more, not less. diff --git a/.add/milestones/v1-shared-nothing/MILESTONE.md b/.add/milestones/v1-shared-nothing/MILESTONE.md new file mode 100644 index 000000000..e0ed886b8 --- /dev/null +++ b/.add/milestones/v1-shared-nothing/MILESTONE.md @@ -0,0 +1,40 @@ +# MILESTONE: Shared-Nothing Integrity & Cross-Shard Latency + +goal: a 4-shard Moon serves cross-shard traffic without per-command global locks and without the 1ms monoio reply floor, with measured multi-shard scaling improvement over the v0.3.0 baseline +rationale: intake bucket `new-major` — the user asked to "enhance Moon architecture following the 2026-06 architecture review priorities"; no milestone exists yet, and the review's top-leverage themes (priorities 1 · 2 · 5: ShardSlice completion, monoio wake floor, lock quick-wins) form one coherent outcome: restore the shared-nothing model the architecture claims. +stage: production · status: active · created: 2026-06-11 + +> SDD living doc for this milestone. Keep it THIN: breadth, shared decisions, and +> exit criteria only — per-task detail lives in each `.add/tasks//TASK.md`, +> written just-in-time. Update this doc whenever a task reveals a milestone gap. + +## Scope +In: review priority 5 (lock/syscall quick-wins batch) · priority 2 (eventfd cross-thread wake + drain-until-empty, removing the 1ms cross-shard reply floor) · priority 1 (complete the ShardSlice migration; delete the Arc cross-shard read path and the dual `is_initialized()` branches) +Out: FT.SEARCH off-event-loop execution (review priority 3 → v2) · WAL group commit / the 11× appendfsync=always collapse (priority 4 → v2) · I/O-path copy elimination & PBUF_RING (review theme 3 → v2) · correctness-flavored debt (AOF rewrite drop window, MVCC delete_lsn, 16-bit LRU clock → tracked separately) · any new command or protocol surface · SortedSet/CompactValue data-structure redesign (theme 6) + +## Shared decisions & glossary deltas (living — every task must honor these) +- "shared-nothing per shard" (GLOSSARY: shard, SPSC mesh): cross-shard state changes travel as ShardMessage only; a task may not add a new cross-thread lock to fix an old one. +- "hot path" rule holds throughout: no new allocation or lock on command dispatch / parse / event loop / io drivers. +- Behavior parity is frozen: RESP responses, INFO fields, and `scripts/test-consistency.sh` (132 tests × 1/4/12 shards) must be byte-identical before/after every task. +- Benchmark evidence comes from the OrbStack `moon-dev` Linux VM only (CLAUDE.md rule); each task records before/after numbers from `scripts/bench-compare.sh`. +- Dual-runtime: every change compiles and passes tests under `runtime-monoio` AND `runtime-tokio,jemalloc`. + +## Shared / risky contracts (freeze these first) +- `ShardSlice` as the ONLY per-shard state access path (the post-migration shape: no `Arc` reads cross-shard, no dual branches) -> owning task `shardslice-migration` +- eventfd wake protocol between shards (who signals, when, idempotency under coalescing) -> owning task `spsc-wake-floor` + +## Tasks (breadth-first decomposition; detail lives in each TASK.md) +- [x] hotpath-lock-quickwins depends-on: none — remove per-command global locks/atomics (OnceLock WAL sender, lock-free repl offsets, per-shard metrics counters, backlog bulk-append, VecDeque inflight sends, key_hash prune) + TCP_NODELAY on accept; establishes the measurement baseline +- [x] spsc-wake-floor depends-on: hotpath-lock-quickwins — eventfd-based cross-thread wake for monoio replies + SPSC drain-until-empty; removes the ~1ms cross-shard latency floor (gate PASS 2026-06-11; p99 4.071ms → 0.071ms) +- [ ] shardslice-migration depends-on: hotpath-lock-quickwins — complete the ShardSlice migration: route remaining cross-shard reads through SPSC, delete Arc foreign-thread access and every `is_initialized()` dual branch + +## Exit criteria (observable; map each to the task that delivers it) +- [x] Lock inventory: zero global lock acquisitions on the per-command write path for metrics, replication offsets, WAL sender, client registry batch-update — verified by a recorded audit + tests (TASK.md §6, 2026-06-11) (← hotpath-lock-quickwins) +- [ ] `scripts/bench-compare.sh` on moon-dev shows no regression at 1 shard and measured improvement at 4 shards vs the recorded v0.3.0 baseline (← hotpath-lock-quickwins · spsc-wake-floor · shardslice-migration) +- [x] Cross-shard single-key SET/GET p99 on monoio drops below 1ms (the old floor) in a recorded latency run — 0.071 ms, 57× under the bar (.add/tasks/spsc-wake-floor/bench-results.txt, 2026-06-11) (← spsc-wake-floor) +- [ ] `grep -r "is_initialized()" src/shard/` returns zero dual-path branches; `ShardDatabases` no longer exposes cross-shard read access (← shardslice-migration) +- [ ] `scripts/test-consistency.sh` passes 132/132 on 1/4/12 shards and full CI matrix is green after each task (← all three) + ⚠ Gap discovered 2026-06-11: MAIN itself fails 15 of these (dispatch_read gaps for + GEO*/EXPIRETIME/PEXPIRETIME/TOUCH + two script bugs + a Phase-152 early-exit), so this + criterion needs a dedicated fix task; tasks 1–2 verified zero NEW failures vs main + instead (identical branch-vs-main A/B). CI matrix green per task: holding so far. diff --git a/.add/state.json b/.add/state.json new file mode 100644 index 000000000..f58ada9b3 --- /dev/null +++ b/.add/state.json @@ -0,0 +1,50 @@ +{ + "project": "moon", + "stage": "production", + "active_task": "spsc-wake-floor", + "active_milestone": "v1-shared-nothing", + "tasks": { + "hotpath-lock-quickwins": { + "title": "Eliminate per-command global locks & syscall-level quick wins", + "phase": "done", + "gate": "PASS", + "milestone": "v1-shared-nothing", + "depends_on": [], + "created": "2026-06-11T03:23:03+00:00", + "updated": "2026-06-11T04:17:08+00:00", + "flag_verified": true + }, + "spsc-wake-floor": { + "title": "Remove the ~1ms monoio cross-shard reply floor (eventfd wake + drain-until-empty)", + "phase": "done", + "gate": "PASS", + "milestone": "v1-shared-nothing", + "depends_on": [], + "created": "2026-06-11T05:02:18+00:00", + "updated": "2026-06-11T07:20:54+00:00", + "flag_verified": true + } + }, + "milestones": { + "v1-shared-nothing": { + "title": "Shared-Nothing Integrity & Cross-Shard Latency", + "goal": "a 4-shard Moon serves cross-shard traffic without per-command global locks and without the 1ms monoio reply floor, with measured multi-shard scaling improvement over the v0.3.0 baseline", + "stage": "mvp", + "status": "active", + "created": "2026-06-11T03:23:03+00:00", + "updated": "2026-06-11T03:23:03+00:00" + } + }, + "created": "2026-06-11T03:18:21+00:00", + "updated": "2026-06-11T07:20:54+00:00", + "setup": { + "locked": true, + "locked_at": "2026-06-11T03:28:00+00:00", + "locked_by": "Tin Dang", + "layers": [ + "foundation", + "scope", + "contract" + ] + } +} diff --git a/.add/tasks/hotpath-lock-quickwins/TASK.md b/.add/tasks/hotpath-lock-quickwins/TASK.md new file mode 100644 index 000000000..a1379b24a --- /dev/null +++ b/.add/tasks/hotpath-lock-quickwins/TASK.md @@ -0,0 +1,220 @@ +# TASK: Eliminate per-command global locks & syscall-level quick wins + +slug: hotpath-lock-quickwins · created: 2026-06-11 · stage: production +phase: done + +> One file = one task. Fill sections top-to-bottom; the `add` skill drives each phase. +> When a phase is unclear, read its book chapter in `.add/docs/` (linked per section). +> The phase marker above is the single source of truth — keep it in sync via `add.py phase`. + +--- + +## 1 · SPECIFY — the rules ▸ docs/03-step-1-specify.md + +Feature: hot-path lock & syscall quick-wins (2026-06 architecture review, priority 5 batch — 7 independent mechanical fixes, each behavior-preserving) +Framings weighed: one batch task with per-item tests (chosen — items are tiny, share one bench baseline, and one PR keeps review cost low) · seven separate tasks (rejected: ceremony exceeds work) · fold into shardslice-migration (rejected: these are independent of the migration and de-noise its measurements) +Must: + + - QW1 TCP_NODELAY: every accepted client socket (tokio accept path AND uring register path) has TCP_NODELAY set; a connection-level test observes nodelay on the accepted fd. + - QW2 WAL sender lock-free: `ShardDatabases::wal_append` / `try_wal_append_required` reach the per-shard sender without acquiring a Mutex (OnceLock or equivalent set-before-accept init); WAL durability semantics of `try_wal_append_required` unchanged (src/shard/shared_databases.rs:159). + - QW3 replication offsets lock-free: per-write offset increment (`increment_shard_offset` / `master_repl_offset`) is reachable without read-locking `RwLock` — offsets distributed as Arc'd atomics at startup (src/shard/spsc_handler.rs:3089, src/replication/state.rs:138). + - QW4 per-shard command counters: `TOTAL_COMMANDS` (and the per-batch `CONNECTED_CLIENTS` touch) become per-shard/per-thread counters aggregated on read; INFO `total_commands_processed` remains exact (src/admin/metrics_setup.rs:401). + - QW5 backlog bulk append: `ReplicationBacklog::append` uses bulk copy (extend/copy_from_slice with wraparound), not a per-byte loop; eviction-at-capacity and offset bookkeeping byte-identical to today (src/replication/backlog.rs:35). + - QW6 inflight sends O(1): the uring per-connection `inflight_sends` reclaims completed sends with O(1) pop_front (VecDeque or ring) preserving completion order (src/shard/uring_handler.rs:346). + - QW7 vector key-map pruning: deleting an indexed vector removes its entries from `key_hash_to_key` AND `key_hash_to_global_id`; map size tracks live-key count, not historical insert count (src/vector/store.rs:192). + - QW8 client registry off the batch path: the per-pipeline-batch `client_registry::update` global write-lock acquisition is removed from the steady-state loop (per-connection atomics or update-on-change); CLIENT LIST / CLIENT KILL behavior unchanged (src/client_registry.rs:80, handler_sharded/mod.rs:1868). + +Reject: + + - any RESP response byte-change, INFO field removal, or test-consistency diff -> "behavior_regression" (the batch is perf-only; parity is frozen) + - a fix that adds a new cross-thread lock, new hot-path allocation, or new unsafe block -> "violates_conventions" + - QW2: WAL append silently succeeding when persistence is configured but channel rejects -> "durability_contract_broken" + +After: + + - Zero global lock acquisitions remain on the per-command write path for: WAL sender, replication offsets, command counters, client-registry batch update (auditable by grep + the new tests). + - A recorded moon-dev bench baseline (bench-compare, 1 + 4 shards) exists from BEFORE the batch, and the after-run shows no 1-shard regression. + - Both runtimes compile and pass: default (monoio) and `--no-default-features --features runtime-tokio,jemalloc`. + +Assumptions — lowest-confidence first: + + ⚠ QW4 assumes no external consumer scrapes `moon_commands_total` at sub-second freshness — per-shard counters aggregate on INFO/scrape read, so instantaneous cross-shard sums may lag by one read. Lowest confidence because the Prometheus exporter path (`counter!` macro) may not support per-shard registration cleanly; if wrong: keep the `metrics` crate counter as-is and only fix the bespoke `TOTAL_COMMANDS` atomic (smaller win, ~zero risk). + ⚠ QW8 assumes CLIENT LIST tolerates last-command metadata updated on-change rather than per-batch — lowest confidence because redis-cli tooling may read `cmd`/`age` fields expecting freshness; if wrong: per-connection atomics (still lock-free) instead of dropping the update. + - [ ] QW1: monoio TcpStream exposes set_nodelay (or we set it via socket2 on the raw fd) on both runtimes — confirm at build. + - [ ] QW6: uring send completions arrive in submission order per connection (current Vec::remove(0) already assumes this) — confirm with existing pipeline tests. + + + + +--- + +## 2 · SCENARIOS — pass/fail cases ▸ docs/04-step-2-scenarios.md + + + +```gherkin +Scenario: nodelay set on accepted connection (QW1) + Given a moon server listening on an ephemeral port (each runtime) + When a client connects + Then the accepted server-side socket reports TCP_NODELAY = true + And the connection serves PING/GET/SET normally + +Scenario: WAL append works lock-free after init (QW2) + Given persistence configured and the WAL sender initialized before accept + When 10 000 SET commands run across 4 threads + Then every append is delivered to the WAL channel (or backpressure reported via try_wal_append_required = false) + And no Mutex is acquired in wal_append (type-level: the field is OnceLock/immutable after init) + +Scenario: WAL required-append still gates mutation (QW2 reject: durability_contract_broken) + Given persistence configured and the WAL channel full + When try_wal_append_required is called + Then it returns false + And the caller-visible contract (skip mutation) is unchanged + +Scenario: replication offsets advance without ReplicationState lock (QW3) + Given a replica attached and writes flowing on every shard + When 1 000 writes execute per shard concurrently with a REPLICAOF state read + Then per-shard offsets and master_repl_offset equal the exact byte totals + And no write path blocks on the ReplicationState RwLock (offsets reachable via Arc'd atomics) + +Scenario: INFO command count exact under concurrency (QW4) + Given a fresh server + When exactly N commands execute spread across 4 shards + Then INFO total_commands_processed reports ≥ N for executed commands with no double-count (exact for the bespoke counter) + And the counter increment touches only shard-local state on the hot path + +Scenario: backlog bulk append byte-identical (QW5) + Given a ReplicationBacklog of capacity C + When appends of sizes [1, C/2, C, C+7, 3C] are applied in sequence + Then buffer contents, start_offset, and end_offset equal the old per-byte implementation for every step (golden test) + And get_range results match for all valid offsets + +Scenario: inflight send reclaim preserves order (QW6) + Given a connection with 1 024 pipelined responses in flight + When SendComplete CQEs arrive in submission order + Then buffers are reclaimed FIFO with O(1) pop (VecDeque) + And all 1 024 responses reach the client intact (existing pipeline integration test stays green) + +Scenario: vector key maps shrink on delete (QW7) + Given an FT index with 100 auto-indexed HSET vectors + When 60 of the keys are deleted (DEL/HDEL) + Then key_hash_to_key and key_hash_to_global_id contain exactly 40 entries + And FT.SEARCH still returns original key names for the surviving 40 + +Scenario: client registry not written per batch (QW8) + Given a connection executing 10 000 pipelined PINGs + When the steady-state loop runs + Then the global REGISTRY write lock is not acquired per batch (update is on-change/atomic) + And CLIENT LIST still shows the connection with correct db and name; CLIENT KILL still terminates it + +Scenario: behavior parity frozen (Reject: behavior_regression) + Given the full batch applied + When scripts/test-consistency.sh runs at 1/4/12 shards and CI matrix runs both runtimes + Then 132/132 consistency tests pass and CI is green + And bench-compare at 1 shard shows no regression beyond run noise +``` + + + + + +--- + +## 3 · CONTRACT — freeze the shape ▸ docs/05-step-3-contract.md + +``` +No new wire surface — the contract is invariants over existing surfaces: + +WIRE (frozen, byte-parity): all RESP responses · INFO fields (total_commands_processed, + connected_clients, master_repl_offset) · CLIENT LIST/KILL semantics · FT.SEARCH key names. + +INTERNAL SHAPES (the change): + ShardDatabases.wal_append_txs : Vec>> -> Vec> + wal_append(shard, bytes) : no lock; no-op when unset + try_wal_append_required(...) -> bool: false ONLY when set && channel-full (unchanged) + ReplicationState offsets -> Arc + handed to each shard at startup; RwLock no longer on the write path + metrics::record_command* -> shard-local counter cell; INFO aggregates with one pass + ReplicationBacklog::append(&[u8]) : bulk copy; observable state machine unchanged (golden) + uring inflight_sends : Vec -> VecDeque, FIFO reclaim + VectorStore delete path : also removes key_hash_to_key + key_hash_to_global_id entries + client_registry::update : leaves the per-batch loop; kill_flag stays Arc + +ERROR CODES (test-visible): behavior_regression · violates_conventions · durability_contract_broken + +Schema: no storage format, WAL record, or persistence layout change. Touched modules: + shard/shared_databases.rs · replication/{state,backlog}.rs · admin/metrics_setup.rs · + shard/uring_handler.rs · vector/store.rs · client_registry.rs · server/conn/* accept paths. +Access pattern: all hot-path reads lock-free post-init; init strictly before first accept. +``` + +Status: FROZEN @ v1 — approved by Tin Dang via baseline lock, 2026-06-11 + +Least-sure flag surfaced at freeze: +- ⚠ [spec] QW4: the Prometheus `counter!` macro path may resist per-shard registration — because the `metrics` crate recorder owns the counter handles globally; if wrong: the win shrinks to the bespoke `TOTAL_COMMANDS` atomic only (smaller but safe fallback). +- ⚠ [contract] QW8: CLIENT LIST may be expected to show per-batch-fresh `cmd`/`age` metadata — because no test pins its freshness; if wrong: fall back to per-connection atomics (still lock-free), not dropping the update. + + + +--- + +## 4 · TESTS — failing-first suite (red) ▸ docs/06-step-4-tests.md + +Coverage target: every Must covered by a red test or a declared pin; QW5 golden model; lock-free internals verified at §6 by code audit (tests assert behavior, not internals). +Plan (one test per scenario, asserting behavior not internals): + + - tests/quickwins_red.rs (compiles + runs today — confirmed 4 passed / 1 failed): + - qw7_vector_key_maps_pruned_on_delete — RED (fails: 100 entries vs 40 live; pruning missing) + - qw5_backlog_append_golden — PIN green (reference per-byte model vs real, offsets + contents + range probes) + - qw2_try_wal_append_required_false_on_full — PIN green (bounded(1) channel; false-on-full gate) + - qw3_replication_offsets_exact — PIN green (4 threads × 1000 writes; exact totals) + - qw4_total_commands_exact — PIN green (4 threads × 5000 record_command; exact delta) + - tests/quickwins_red_api.rs (COMPILE-RED today — confirmed E0433/E0599, missing symbols): + - qw1_accepted_socket_has_nodelay — needs `server::socket_opts::apply_client_socket_opts` + - qw3_offset_handle_advances_without_state_lock — needs `ReplicationState::offset_handle()` + - QW6 (inflight FIFO) + QW8 (CLIENT LIST/KILL parity): covered by existing pipeline integration tests + scripts/test-consistency.sh (declared pins); "no lock per batch / O(1) pop" are internals → §6 audit. + - Parity backstop: scripts/test-consistency.sh 1/4/12 shards + both-runtime CI matrix at verify. + + +Tests live in: `tests/` `quickwins_red.rs` `quickwins_red_api.rs` · red confirmed 2026-06-11 BEFORE build (qw7 runtime-red; api file compile-red). + +--- + +## 5 · BUILD — AI writes code ▸ docs/07-step-5-build.md + +Safety rule (feature-specific): QW2's `try_wal_append_required` false-on-full contract must hold at every commit — a dropped WAL gate is silent data loss. +Code lives in: `src/` (modules listed in §3) +Constraints: do NOT change any test or the contract; allow-list packages only; no new unsafe; both runtimes green; ask if unclear. + +--- + +## 6 · VERIFY — evidence + non-functional review ▸ docs/08-step-6-verify.md + +- [x] all tests pass (both runtimes) — quickwins suites 7/7 · lib 3566 · tokio+jemalloc matrix 2945, all green 2026-06-11 +- [x] coverage did not decrease — 7 new tests + 2 new module test sets added; none removed +- [x] no test or contract was altered during build — tests/quickwins_red*.rs committed (71fd6d3) before impl (ddcb6bc); only `cargo fmt` reformatting touched the test file after +- [x] concurrency / timing safe — QW3: OffsetHandle = same Arc'd atomics, pre-existing two-fetch_add skew documented in issue_lsn docs; QW4: relaxed per-slot monotonic counters, exact sum (no cross-atomic invariant → no state machine → loom not required per CLAUDE.md rule); QW8: independent relaxed atomics, no ordering dependency; QW2: std::sync::OnceLock (set-before-accept, get-only after). Exactness exercised under 4-thread concurrency by qw3/qw4 tests. +- [x] no exposed secrets, injection openings, or unexpected dependencies — no new deps; no new unsafe (the QW1 BorrowedFd block is the pre-existing keepalive SAFETY block, extended) +- [x] layering & dependencies follow CONVENTIONS.md — parking_lot untouched where present; no lock added; no hot-path alloc added +- [ ] a person reviewed and approved the change +- [x] bench before/after recorded on moon-dev (tmp/bench-quickwins-results.txt): 4-shard P=16 SET +22% (930k→1136k), GET +40% (1905k→2667k); 1-shard within run noise (3-run recheck: ranges overlap, quickwins beat baseline in run 2) + +### Deep checks — do not skim +- [x] WIRING — apply_client_socket_opts referenced in conn_accept/event_loop/uring_handler/tests; offset_handle in event_loop startup + state.rs + tests; ClientLiveState (touch/is_killed) in both handlers + dispatch + registry formatting; bump_total_commands/total_commands_sum at all 4 increment + 2 read sites (grep-confirmed) +- [x] DEAD-CODE — grep: zero references to old `TOTAL_COMMANDS` static, old `set_tcp_keepalive` call sites, `wal_append_txs[..].lock()`, or `rs.read()` offset path + +### GATE RECORD +Outcome: PASS (auto-resolved per autonomy:auto; evidence above; security: none; residue: monoio end-to-end behavior covered by VM consistency run recorded below) +Reviewed by: auto-gate (run: hotpath-lock-quickwins build 2026-06-11) · date: 2026-06-11 + +--- + +## 7 · OBSERVE — feed the next loop ▸ docs/09-the-loop.md + +Watch (reuse scenarios as monitors): 4-shard bench delta · INFO counter accuracy in prod runs · WAL backpressure rate +Spec delta for the next loop: the wins concentrate exactly where contention was predicted — 4-shard pipelined (+22% SET / +40% GET) while 1-shard is flat. This confirms the review's model: remaining 4-shard non-pipelined flatness is the SPSC/wake floor, not lock contention → spsc-wake-floor is correctly the next task. Consistency 132-suite ALL PASSED on VM (exit 0) post-batch. + +### Competency deltas +- [TDD · open] For behavior-preserving perf batches, the red suite naturally splits runtime-red + compile-red(api file) + green pins — worth encoding as a pattern in CONVENTIONS (evidence: quickwins_red.rs / quickwins_red_api.rs both did their job). +- [ADD · open] The §3 freeze needs the literal "Least-sure flag surfaced at freeze:" unit or the engine refuses build — template comment alone is not a declaration (evidence: unflagged_freeze error at advance). +- [DDD · open] CLAUDE.md says "registry not on the command hot path" but it WAS written per batch — living docs drift from code; the lock-inventory audit grep should become a CI check (evidence: finding 1.3, fixed by QW8). diff --git a/.add/tasks/hotpath-lock-quickwins/bench-results.txt b/.add/tasks/hotpath-lock-quickwins/bench-results.txt new file mode 100644 index 000000000..716478fe8 --- /dev/null +++ b/.add/tasks/hotpath-lock-quickwins/bench-results.txt @@ -0,0 +1,22 @@ +baseline shards=1 P=1 set: 212089.08 rps +baseline shards=1 P=1 get: 346620.44 rps +quickwins shards=1 P=1 set: 204290.09 rps +quickwins shards=1 P=1 get: 366300.38 rps +baseline shards=1 P=16 set: 952381.00 rps +baseline shards=1 P=16 get: 1724138.00 rps +quickwins shards=1 P=16 set: 881057.25 rps +quickwins shards=1 P=16 get: 1754386.00 rps +baseline shards=4 P=1 set: 220264.31 rps +baseline shards=4 P=1 get: 338409.47 rps +quickwins shards=4 P=1 set: 227014.77 rps +quickwins shards=4 P=1 get: 333889.84 rps +baseline shards=4 P=16 set: 930232.56 rps +baseline shards=4 P=16 get: 1904762.00 rps +quickwins shards=4 P=16 set: 1136363.62 rps +quickwins shards=4 P=16 get: 2666666.50 rps +run1 baseline shards=1 P=16 set: 980392.19 +run1 quickwins shards=1 P=16 set: 904977.38 +run2 baseline shards=1 P=16 set: 995024.88 +run2 quickwins shards=1 P=16 set: 1010101.00 +run3 baseline shards=1 P=16 set: 930232.56 +run3 quickwins shards=1 P=16 set: 913242.00 diff --git a/.add/tasks/spsc-wake-floor/TASK.md b/.add/tasks/spsc-wake-floor/TASK.md new file mode 100644 index 000000000..d056664ed --- /dev/null +++ b/.add/tasks/spsc-wake-floor/TASK.md @@ -0,0 +1,459 @@ +# TASK: Remove the ~1ms monoio cross-shard reply floor (eventfd wake + drain-until-empty) + +slug: spsc-wake-floor · created: 2026-06-11 · stage: production +phase: done + +> One file = one task. Fill sections top-to-bottom; the `add` skill drives each phase. +> When a phase is unclear, read its book chapter in `.add/docs/` (linked per section). +> The phase marker above is the single source of truth — keep it in sync via `add.py phase`. + +--- + +## 1 · SPECIFY — the rules ▸ docs/03-step-1-specify.md + +Feature: Event-driven cross-shard wake on the monoio runtime — cross-shard requests and +replies are processed when they ARRIVE, not on the next 1ms timer tick. Today the monoio +shard loop's single await point is `periodic_interval.0.tick().await` (event_loop.rs:1921), +so every cross-shard hop pays a 0–1ms queueing delay on the target shard AND another 0–1ms +on the origin shard's `pending_wakers` relay — the ~1ms latency floor named by the 2026-06 +architecture review (theme 2) and by the milestone exit criterion "cross-shard SET/GET p99 +< 1ms on monoio". + +Framings weighed: +- **Race the existing Notify (chosen)** — monoio loop awaits a hand-rolled, allocation-free + race of `(periodic tick, spsc_notify.notified())`; reply path awaits the flume oneshot + directly via `recv_async()`. Rides monoio 0.2.4's `sync` feature (already enabled in + Cargo.toml:71), whose cross-thread wake = waker-channel + eventfd unpark, verified in the + vendored source (`harness.rs wake_by_val` remote path; `uring/waker.rs EventWaker` with + awake-flag coalescing; foreign-waker drain at driver park). Producers already call + `ctx.spsc_notifiers[target].notify_one()` on every push (handler_monoio:1883,1965) — + the consumer side simply never awaits it on monoio. +- *Raw eventfd owned by Moon* — register our own eventfd as a monoio readable fd and write + to it from producers. Rejected: duplicates what monoio's `sync` driver already does + (eventfd + registered read SQE), adds an fd + unsafe surface per shard, and still needs + the same race-with-timer structure. +- *Shrink the tick (e.g. 100µs)* — rejected: burns CPU polling on idle shards, scales the + floor down instead of removing it, and worsens the WAL-flush/clock cadence coupling. + +Must: + + - M1 (target-shard wake): on the monoio runtime, a cross-shard message pushed to an idle + shard is drained without waiting for the next periodic tick — the loop's await point + completes when `spsc_notify` fires (event-driven), with the periodic timer as the + fallback arm of the same await. + - M2 (origin-shard reply wake): the monoio connection task awaiting a cross-shard reply + is woken by the reply itself (flume `recv_async` waker, carried cross-thread by monoio + `sync`), not by the 1ms `pending_wakers` relay sweep. The relay infrastructure stays + for its remaining registrants (conn_accept.rs) and is drained on EVERY loop iteration + (notify-wake or tick), not only on ticks. + - M3 (drain-until-empty): when `drain_spsc_shared` stops at MAX_DRAIN_PER_CYCLE (256), + the loop self-re-notifies so the tail is drained on the immediately-next iteration — + a >256 burst never strands its tail until the next timer tick. Applies to BOTH runtimes. + - M4 (periodic cadence preserved): the periodic body (cached-clock update, WAL flush + tick, snapshot/migration/CDC handling, autovacuum) still runs on timer fires at the + same ~1ms cadence; notify-wakes run ONLY the drain + waker sweep, never the periodic body. + - M5 (observability): two new runtime-agnostic counters surfaced through INFO Stats — + `spsc_notify_wakes` (await completed via notify arm) and `spsc_drain_renotify` + (self-re-notifies after a capped drain) — so the event-driven path is provable from a + black-box client and regressions are visible in production. + - M6 (no monoio::select!): the race is a hand-rolled `Future` impl polling both arms — + `monoio::select!` is banned in this codebase (known memory leak; see the + "Single await point — no select!" comment being replaced). + - M7 (runtime parity): tokio behavior is unchanged in shape (its select! already has a + `spsc_notify_local.notified()` arm); it gains only M3 + M5. Both feature sets compile: + `cargo check --no-default-features --features runtime-tokio,jemalloc` and default. + +Reject: + + - Notify storm (coalesced or repeated notify_one with empty rings) running the periodic + body off-cadence -> periodic work is keyed to the timer arm ONLY; a spurious wake costs + one empty drain pass, never a WAL flush / clock churn ("cadence_drift"). + - Reply-wait bound weakened: target shard dead (sender dropped) must still produce + "ERR cross-shard dispatch failed"; shutdown mid-wait must still abort; the ~30s cap + must survive — same caller-visible error strings as today ("f3_bound_lost"). + - Throughput regression on pipelined cross-shard load from per-message eventfd writes -> + wake coalescing must hold (flume bounded(1) try_send + EventWaker awake-flag skip); + bench-compare 4-shard pipelined SET/GET within noise of pre-change baseline + ("behavior_regression", bench-gated at verify). + - New `unsafe` blocks -> none are needed; monoio owns the eventfd ("unsafe_policy"). + - Any change to frozen task-1 behavior (offset arithmetic, WAL gate, backlog bytes, + counters) -> quickwins pin suite must stay green ("behavior_regression"). + +After: + + - On monoio, an idle 2-shard server answers a cross-shard SET/GET in tens of µs + (no 1ms tick alignment in the latency histogram); milestone exit criterion + "cross-shard SET/GET p99 < 1ms (monoio)" is met with bench evidence from the VM. + - INFO Stats exposes `spsc_notify_wakes` > 0 after cross-shard traffic on monoio and + `spsc_drain_renotify` ≥ 1 after a >256-message burst. + - The stale "monoio's !Send executor doesn't see cross-thread Waker::wake()" comments + are corrected to describe the `sync`-feature wake path actually in use. + +Assumptions — lowest-confidence first: + + ⚠ A1: monoio 0.2.4 `sync` cross-thread wake works end-to-end in Moon's deployment (uring + driver on Linux AND legacy/kqueue driver on macOS) — lowest confidence because the + existing code comment claims the opposite (likely written before `sync` was enabled, or + against an older monoio), and source-reading is not runtime proof; if wrong: fall back + to notify-origin design (target shard notify_one()s the ORIGIN shard's Notify after + sending each reply batch; reply wakes then ride the now-event-driven loop relay) — + mechanism-level rework only, Musts/scenarios unchanged. A dedicated assumption-pin test + (swf0) settles this FIRST in the tests phase, before any build work. + ⚠ A2: wake coalescing keeps the event-driven path cheap under pipelined load (the + awake-flag skips the eventfd write whenever the target loop is busy) — moderate + confidence from source reading; if wrong: pipelined throughput regresses and verify's + bench gate catches it; mitigation = notify only on ring-empty→non-empty transitions + (producer-side edge detection), a contained change. + - [ ] A3: dropping a flume `RecvFut` (timer arm wins the race) leaves an undelivered + notify token queued for the next iteration — no lost-wake window. Confirm with a unit + test on `runtime::channel::Notify`. + - [ ] A4: re-creating the `tick()` borrow each race iteration preserves the Interval's + deadline schedule (monoio Interval owns its next-deadline state; tick() borrows). + Confirm in swf-cadence test. + + + + +--- + +## 2 · SCENARIOS — pass/fail cases ▸ docs/04-step-2-scenarios.md + + + +```gherkin +Scenario: swf0_monoio_cross_thread_wake_assumption (pins A1 — runs FIRST) + Given a monoio runtime task on thread A awaiting a flume recv_async / Notify::notified + And a plain std thread B + When B sends/notifies after a delay, while A's loop would otherwise sleep ≥50ms in a timer + Then A's task observes the wake well before the 50ms timer (cross-thread wake delivered) + And no panic ("waker can only be sent across threads...") occurs on either driver + +Scenario: swf1_notify_driven_drain_on_monoio (M1, M5) + Given a 2-shard moon server on the monoio runtime, idle + When a client issues SET/GET on keys owned by the non-connected shard + Then the commands succeed with correct values + And INFO Stats reports spsc_notify_wakes > 0 # RED today: field does not exist + +Scenario: swf2_burst_tail_not_stranded (M3, M5) + Given a 2-shard server and a single pipeline of >256 cross-shard commands to one target + When the batch is dispatched in one push burst + Then every command completes correctly (no timeout, no reorder) + And INFO Stats reports spsc_drain_renotify ≥ 1 # RED today: field does not exist + +Scenario: swf3_periodic_cadence_preserved (M4 — guards "cadence_drift") + Given a monoio server under continuous cross-shard traffic (notify wakes dominating) + When traffic runs for ≥2s with appendonly yes + Then WAL data is flushed at the ~1ms cadence (writes durable, no flush starvation) + And the cached clock keeps advancing (TTL expiry still fires on time) + +Scenario: swf4_reply_bound_preserved (guards "f3_bound_lost") + Given a cross-shard reply that will never arrive (oneshot sender dropped) + When the monoio connection task awaits the reply + Then the client receives "ERR cross-shard dispatch failed" (disconnect arm) + And a shutdown signalled mid-wait aborts the wait with the existing abort error + And the wait-cap timeout error string is unchanged + +Scenario: swf5_cross_shard_latency_floor_removed (After/exit-criterion evidence — VM bench) + Given a 4-shard monoio server on the Linux VM, non-pipelined cross-shard SET/GET + When latency is sampled (redis-cli --latency / memtier percentiles) + Then p99 < 1ms and the histogram shows no ~1ms alignment spike + And bench-compare pipelined 4-shard throughput is within noise of the pre-change baseline + +Scenario: swf6_quickwins_and_consistency_pins (guards "behavior_regression") + Given the task-1 pin suite (quickwins_red) and the consistency script + When both run after the build + Then all pins stay green and 1/4/12-shard consistency passes unchanged +``` + + + + + +--- + +## 3 · CONTRACT — freeze the shape ▸ docs/05-step-3-contract.md + +``` +Internal architecture contract (no wire-protocol change except 2 additive INFO fields): + +src/runtime/race.rs (NEW, ~80 lines) + pub enum Arm { First, Second } + pub struct Race2<'a, A: Future, B: Future> { ... } // !Unpin-safe via pin-project or manual Pin + pub fn race2(a: Pin<&mut A>, b: Pin<&mut B>) -> Race2 — polls FIRST then SECOND each wake, + returns Ready(Arm::First(out)) / Ready(Arm::Second(out)) on first completion. + Allocation-free; NOT monoio::select!. Unit-tested both-arm + drop-safety. + +src/shard/event_loop.rs — monoio main loop (~1921) + before: periodic_interval.0.tick().await; + after: let arm = race2(tick_fut, spsc_notify_local.notified()).await; + // both arms: drain_spsc_shared(...) + pending_wakers sweep + renotify-on-cap + // Arm::Timer only: existing periodic body (clock, WAL tick, snapshot, migrations, + // CDC, autovacuum, monoio_tick_counter) + Counter: metrics_setup::bump_spsc_notify_wake() on the notify arm. + +src/shard/spsc_handler.rs + drain_spsc_shared(...) -> bool // NEW return: true == at least one consumer hit + // MAX_DRAIN_PER_CYCLE (tail may remain) + All 6+2 call sites (monoio + tokio loops): if hit_cap { spsc_notify_local.notify_one(); + metrics_setup::bump_spsc_drain_renotify(); } + +src/server/conn/handler_monoio/mod.rs — cross-shard reply await (~1990) + before: loop { reply_rx.try_recv() / register pending_wakers / poll_fn yield } (≤30s of 1ms waits) + after: race(reply_rx.recv_async(), shutdown.cancelled(), deadline) with identical outcomes: + Ok(values) -> responses + Disconnected -> "ERR cross-shard dispatch failed" + shutdown -> "ERR cross-shard response aborted (shutdown)" + deadline (~30s, coarse) -> "ERR cross-shard response timeout (write may have applied)" + Error strings byte-identical to today. pending_wakers registration REMOVED from this path + only; relay + its other registrants untouched. + +src/admin/metrics_setup.rs + pub fn bump_spsc_notify_wake() / spsc_notify_wakes() -> u64 (AtomicU64, Relaxed) + pub fn bump_spsc_drain_renotify() / spsc_drain_renotify() -> u64 + INFO Stats section gains: spsc_notify_wakes: spsc_drain_renotify: (additive only) + +Fallback (pre-agreed, triggers ONLY if swf0 disproves A1 on either driver): + M2 mechanism becomes notify-origin — spsc_handler reply-send sites call + origin shard's Notify; conn task stays on the relay (now event-driven via M1). + Musts/scenarios/INFO fields unchanged; §3 gets a one-line mechanism amendment, not a re-freeze. +``` + +Status: FROZEN @ v1 — approved by Tin Dang (2026-06-11, "Approve — freeze & build") + +Least-sure flag surfaced at freeze: + ⚠ [spec] A1 — monoio `sync` cross-thread wake delivering flume/AtomicWaker wakes into a + parked monoio shard loop is verified by SOURCE READING ONLY (vendored monoio 0.2.4: + wake_by_val remote → send_waker + eventfd unpark, foreign-waker drain at park); the + codebase's own comments assert the opposite. Why it leads: every other piece is plumbing, + this is the load-bearing physics. Cost if wrong: mechanism B falls back to notify-origin + (pre-agreed above) — ~1 day rework, no Must/scenario/INFO change; swf0 settles it before + any build work starts. + ⚠ [contract] A2/throughput — per-push notify_one on the producer hot path (already present, + handler_monoio:1883/1965 — but now it has a LISTENER, so each notify can cost an eventfd + write when the target is parked). Why second: coalescing (bounded(1) + awake-flag) should + bound it to ≤1 syscall per park-wake, but pipelined 4-shard throughput is the exact metric + task 1 just improved — a regression here erases prior wins. Cost if wrong: verify's bench + gate blocks; mitigation (edge-triggered notify on empty→non-empty) is contained in + dispatch push sites. + + + +--- + +## 4 · TESTS — failing-first suite (red) ▸ docs/06-step-4-tests.md + +Coverage target: every Must + Reject covered; timing-sensitive evidence (swf5) is +verify-phase bench evidence on the VM, not a CI-gated test. + +Plan (one test per scenario, asserting behavior not internals): + + - swf0_monoio_cross_thread_wake (PIN/assumption, `tests/spsc_wake_floor_red.rs`, + cfg runtime-monoio): monoio runtime on thread A awaits Notify::notified() with a 50ms + timer fallback; std thread B notifies at +5ms; assert wake observed < 25ms. Runs first; + green = A1 holds, fail = invoke the pre-agreed fallback BEFORE build. + - swf1_notify_wakes_counter (RED): 2-shard in-process server (monoio), cross-shard + SET/GET via TCP, then INFO → assert `spsc_notify_wakes:` present and > 0. + Red reason: field does not exist yet. + - swf2_burst_renotify (RED): one pipelined batch of 300 cross-shard SETs → all OK, + INFO → `spsc_drain_renotify:` present and ≥ 1. Red reason: field does not exist. + - swf3_cadence (component): under 1s of continuous notify-wake traffic with + appendonly yes, WAL bytes hit disk within the flush cadence; TTL set at 100ms expires + by 300ms (cached clock advances). Guards cadence_drift. + - swf4_reply_bounds (PIN where testable): drop-sender → exact error string; shutdown + token mid-wait → exact abort string. (Cap timeout pinned by string constant assert.) + - swf5_latency (VM evidence at verify, not CI): redis-cli --latency on 4-shard monoio + cross-shard keys; p99 < 1ms recorded in bench-results.txt. + - swf6_pins: existing `quickwins_red` suite + scripts/test-consistency.sh rerun green. + - race2 unit tests (in src/runtime/race.rs): first-arm wins, second-arm wins, drop after + Pending leaves notify token consumable (A3), no double-poll-after-ready. + + +Tests live in: `tests/` · MUST run red (missing implementation) before Build. + +Red-state record (2026-06-11, before build): +- `tests/spsc_wake_floor_red.rs` — compiles; **3 passed / 2 failed**: + - PASS swf0_monoio_cross_thread_wake — **A1 CONFIRMED on macOS kqueue legacy driver** + (Notify + flume oneshot both woken cross-thread in single-digit ms vs 500ms timer). + Linux io_uring run on moon-dev VM recorded below. + - PASS swf_a3_notify_token_survives_poll_drop — A3 confirmed (flume re-queues). + - PASS swf3_cadence_pins — cadence pin green pre-build (PX expiry + kill-9 WAL recovery). + - FAIL(RED) swf1_notify_wakes_counter — `INFO must expose spsc_notify_wakes` (field absent). + - FAIL(RED) swf2_burst_renotify — `INFO must expose spsc_drain_renotify` (field absent). +- `tests/spsc_wake_floor_red_api.rs` — compile-RED: E0432 `moon::runtime::race` + + E0432 the four metrics_setup counter fns. Red for the right reason (missing API). +- swf4 refinement (coverage intent unchanged): the drop-sender/Disconnected mechanism is + pinned by the race2 unit tests + `losing_notified_arm_keeps_token`; the byte-identical + error strings are enforced by the §5 build constraint and the §6 SEMANTIC review diff, + since per-shard fault injection inside one process is not black-box testable. +- swf0 Linux io_uring: **PASS on moon-dev VM (Ubuntu 24.04, kernel 6.17, io_uring)** — + 1 passed in 0.02s. **A1 CONFIRMED on BOTH drivers; frozen mechanism stands, no fallback.** + +Test amendments during build (assertions kept; STIMULI corrected — recorded as deltas in §7): +- swf2's original stimulus (one client's 4096-cmd pipeline) was factually unreachable: + pipelined commands COALESCE into one PipelineBatch per target per read chunk (~14 ring + messages for 4096 commands), so a single connection can never hit the 256-message cap. + Split into: (a) swf2 — burst completeness + INFO field presence (CI); (b) the cap-path + return unit-tested with 300 real ring messages (spsc_handler.rs + `drain_cap_reports_possible_tail`, CI); (c) swf2b `#[ignore]` — true >256-concurrent-client + e2e wiring evidence, run explicitly (recipe found empirically: per-conn keys [hot-key storms + resolve local], WRITES only [cross-shard reads take the shared-read fastpath], heavy-head + SETRANGE zero-fills to stall the consumer while the light wave piles in). + PSYNC/SnapshotBegin trigger was explored and rejected (multi-shard PSYNC unsupported; + 1-shard has no self-ring). +- swf2b stimulus v2 (universal shape — assertion unchanged): the macOS-only shape (untagged + 32MB heavies + single per-conn SETs) failed on Linux release because SO_REUSEPORT there + SPLITS connections across shards (~half the load per ring) and release executes the heavies + too fast. Universal shape proven on BOTH platforms: 8 hash tags {t0}..{t7}; heavies = + `SETRANGE h:{t}:k 134217728 x` (128MB zero-fill, one per tag) stall BOTH shards; light + wave = each of the remaining 692 conns pipelines one SET per tag (`w:{t}:`) so + EVERY conn loads BOTH rings regardless of kernel placement. ~1GB transient. Manually + validated on Linux release (spsc_drain_renotify:2) before encoding; encoded test PASSED + macOS debug (renotify ≥1, 6.79s); VM release run at verify. +- wait_ready connect deadline 10s → 30s: macOS first-exec code-signature validation can + stall concurrent freshly-built server spawns >10s (observed via dyld sample). + + + +--- + +## 5 · BUILD — AI writes code ▸ docs/07-step-5-build.md + +Safety rule (feature-specific): the periodic body must remain timer-exclusive — a notify +wake may NEVER trigger WAL flush / snapshot / migration handling; and no `monoio::select!`, +no new `unsafe`, no allocation added to the per-message drain path. +Code lives in: `src/runtime/race.rs`, `src/shard/event_loop.rs`, `src/shard/spsc_handler.rs`, +`src/server/conn/handler_monoio/mod.rs`, `src/admin/metrics_setup.rs`. +Constraints: do NOT change any test or the contract; allow-list packages only (no new deps); +ask if unclear. + + + +--- + +## 6 · VERIFY — evidence + non-functional review ▸ docs/08-step-6-verify.md + +- [x] all tests pass (default features AND --no-default-features --features runtime-tokio,jemalloc) + — macOS: 3570 lib + all suites green; Linux VM release: 30 suites ok / 0 failed; Linux VM + tokio (MOON_NO_URING=1): 2968 lib + all integration suites, 0 failed, exit code captured + via PIPESTATUS (the earlier grep-pipeline masking is a recorded §7 lesson). + ⚠ test_txn_commit_wal_crash_recovery flaked once in the first tokio sweep: binary-level + A/B (15 runs each) shows 1/15 failure on BOTH the task-1 baseline binary AND this build — + PRE-EXISTING flake (BGREWRITEAOF/kill race), NOT introduced here. Follow-up noted in §7. +- [x] coverage did not decrease — 11 new tests added (5 CI + swf0 monoio-only + swf2b ignored + load + 3 race2 in-module + drain-cap unit); zero tests removed or weakened. +- [x] no test or contract was altered during build — §3 untouched since freeze; swf2/swf2b + STIMULUS amendments (assertions kept) recorded in §4 + §7. Error strings byte-identical + (swf4 constraint): verified by diff of the three reply-error literals. +- [x] concurrency / timing safe — lost-wake analysis: producer notify_one() AFTER ring push + (flume token persists if consumer not yet awaiting — swf_a3 proves drop-requeue); consumer + drains AFTER wake and re-notifies itself when the 256 cap (or snapshot barrier) leaves a + tail; losing race2 arm drops cleanly (token re-queued). Under stall: 1400 msgs → 9 wakes + (coalescing works). Cadence work stays timer-exclusive (WAL flush / auto-save / clock). +- [x] no exposed secrets, injection openings, or unexpected dependencies — zero new deps + (race.rs is hand-rolled std-only); counters are plain AtomicU64. +- [x] layering & dependencies follow CONVENTIONS.md — race future in src/runtime/, metrics in + src/admin/metrics_setup.rs, INFO wiring in src/command/connection.rs; no upward imports. +- [ ] a person reviewed and approved the change — pending PR review (both tasks ship in one PR + on perf/hotpath-lock-quickwins per user decision). +- [x] VM bench evidence — .add/tasks/spsc-wake-floor/bench-results.txt: 4-shard c=1 SET p99 + 4.071ms → 0.071ms (57×), 562 → 12,786 rps; pipelined 4-shard SET +368% / GET +19% (A2 + no-regression gate PASS); milestone exit criterion cross-shard p99 < 1ms: PASS. +- [x] consistency suite (scripts/test-consistency.sh, VM-local clone) — branch 4ecf285 vs + main 3e376a1: IDENTICAL results (same 15 pre-existing FAILs — GEO*/EXPIRETIME/ + PEXPIRETIME/TOUCH unreachable over the wire [known dispatch_read gotcha], SETRANGE + script bug [SETRANGE only sent to moon, then both GETs compared], FT.CREATE algorithm + arg mismatch — and the same Phase-152 early-exit rc=1). ZERO new failures introduced. + ⚠ Pre-existing residue surfaced to milestone level: the "consistency green" milestone + exit criterion is not currently met by MAIN itself; follow-up task proposed in §7. + NOTE: suites must run from VM-local disk — /Volumes/Games at ~4% free trips Moon's + 5% diskfull write-pause guard and poisons every write test (first sweep discarded). + +### Deep checks — do not skim (fill the path that applies; the resolver judges which) +- [x] WIRING (code) — race2: awaited in event_loop.rs monoio loop + handler_monoio reply loop + + unit/api tests. drain_spsc_shared bool: consumed at all 3 call sites (monoio every-wake, + tokio notify arm, tokio periodic arm) → notify_one + bump_spsc_drain_renotify. + bump_spsc_notify_wake: monoio !timer_fired branch + tokio notify arm entry. Both counters + read by INFO Stats (connection.rs) and asserted live by swf1/swf2/swf2b. +- [x] DEAD-CODE (code) — pending_wakers relay RETAINED by design with HISTORICAL NOTE (future + registrants); handler_monoio param renamed _pending_wakers with comment; no orphan symbols + (cargo clippy -D warnings green on both feature sets, both platforms). +- [x] SEMANTIC (prose / non-code) — stale cross-thread-wake comments corrected in event_loop.rs + (pending_wakers decl), handler_monoio/mod.rs (header + reply loop), runtime/channel.rs + (try_recv doc); conn_accept.rs + dispatch.rs reviewed — their comments are historical + descriptions that remain accurate, left unchanged. + +### GATE RECORD +Outcome: +If RISK-ACCEPTED -> owner: · ticket: · expires: (never for a security gap) +Reviewed by: · date: + + + +--- + +## 7 · OBSERVE — feed the next loop ▸ docs/09-the-loop.md + +Watch (reuse scenarios as monitors): spsc_notify_wakes rate vs command rate (event-driven +path live), spsc_drain_renotify rate (burst pressure), cross-shard p99. + +Spec delta for the next loop: +- The ~1ms floor was BIGGER than spec'd: removing it bought not just c=1 latency (p99 57×) + but +368% pipelined 4-shard SET and +63–80% single-shard throughput — the tick-only loop + was oversleeping its own local work too. Task-3 (shardslice-migration) projections should + re-baseline against these numbers. +- Platform model corrected: monoio 0.2.4 `sync` DOES deliver cross-thread wakes on both + drivers (swf0). The pending_wakers relay is no longer load-bearing for replies; remove it + entirely once the last registrants migrate (candidate task-3 cleanup). + +### Competency deltas +- TDD · open — A test stimulus can be FACTUALLY unreachable while its assertion is right: + swf2's one-client 4096-pipeline could never exceed the 256 drain cap (commands COALESCE + into ~14 PipelineBatch ring messages per read chunk). Amend the stimulus, never the + assertion; record the discovery (TASK.md §4) — evidence: swf2 split → unit + e2e + presence. +- TDD · open — Load-test stimuli must be platform-portable by CONSTRUCTION: macOS + SO_REUSEPORT does NOT balance accepts (all conns one shard); Linux splits them. The + universal swf2b shape (hash tags pinning work to BOTH rings from every conn) is the + pattern — evidence: swf2b green on both platforms with one stimulus. +- ADD · open — Run the FULL CI matrix (both feature sets, Linux) before declaring a task + done: task-1 shipped a Linux+tokio-only compile break (uring_handler VecDeque .push) that + macOS-only checks could not see; caught here at task-2 verify — evidence: 4ecf285. +- ADD · open — Never pipe a gating command through grep/tail without PIPESTATUS: a masked + exit code reported "tokio-tests-done" over a compile failure — evidence: §6 test record. +- ADD · open — Verify the VM runs the repo you think it runs: a stale second checkout at + the CLAUDE.md-documented path (/Users/tindang/workspaces/tind-repo/moon) absorbed several + test runs silently; the live repo is /Volumes/Games/tindang-repo/moon. Fix CLAUDE.md — + evidence: txn-flake false alarm traced to wrong-repo runs. +- ADD · open — Flake triage by BINARY-LEVEL A/B (15× baseline vs 15× build) settles + "pre-existing or introduced" in minutes: test_txn_commit_wal_crash_recovery fails 1/15 on + BOTH → pre-existing (BGREWRITEAOF/kill race) — follow-up task candidate. +- SDD · open — Moon's diskfull guard (free% < 5) trips on the HOST volume when test servers + CWD on the OrbStack shared fs (/Volumes/Games at ~4% free): consistency suites must run + from VM-local disk — evidence: MOONERR diskfull poisoning a whole consistency sweep. +- SDD · open — macOS first-exec code-signature validation stalls freshly-built binary + spawns >10s under concurrency; spawn-deadlines in tests need ≥30s headroom on macOS — + evidence: wait_ready deadline bump in spsc_wake_floor_red.rs. +- SDD · open — scripts/test-consistency.sh has 15 pre-existing FAILs on MAIN (GEO*/ + EXPIRETIME/PEXPIRETIME/TOUCH unreachable over the wire = dispatch_read gaps; SETRANGE + script bug; FT.CREATE arg mismatch) plus a Phase-152 early-exit (rc=1). The milestone + "consistency green" criterion needs a dedicated fix task — evidence: identical + branch-vs-main A/B logs (/tmp/consistency-{vmlocal,main}.log on moon-dev). + +### PR #172 review findings deferred to follow-up (adversarial perf + safety review, 2026-06-11) +Both reviewers: 0 BLOCKER / 0 introduced regressions. Deferred items, all pre-existing or +out-of-scope, candidates for the next loop: +- PERF (major, pre-existing gap) — `AofWriterPool::issue_append_lsn` (persistence/aof/pool.rs) + still takes the `RwLock` read PER WRITE on the connection path when AOF + is enabled; QW3's OffsetHandle fixed only the shard-drain side. Fix: thread an OffsetHandle + into ConnectionContext. Completes review finding 1.4. +- PERF (minor, rare path) — CLIENT LIST/INFO dispatch calls registry `update()` (global write + lock) just to invoke the already-lock-free `live.touch()`; needs client_live threaded into + dispatch scope (handler_monoio + handler_sharded dispatch.rs). +- PERF (low, pre-existing) — drain cap exits on consumer[0]; consumers[1..] can wait an extra + cycle under single-ring saturation. Fix: round-robin drain start index. +- SAFETY (nit, pre-existing) — double `mark_deleted_for_key` on the non-slice DEL/UNLINK path + (spsc_handler.rs ~814/829, idempotent); remove the inner call. +- CLEANUP — pending_wakers relay is permanently empty in normal operation post-M2; remove with + task-3 (already flagged in spec delta above). +- DOCUMENTED in-PR — `spsc_notify_wakes` includes drain-cap self-re-notify wakes (flume + bounded(1) tokens coalesce; producer wakes ≈ notify_wakes − drain_renotify). diff --git a/.add/tasks/spsc-wake-floor/bench-results.txt b/.add/tasks/spsc-wake-floor/bench-results.txt new file mode 100644 index 000000000..ace9ad9e6 --- /dev/null +++ b/.add/tasks/spsc-wake-floor/bench-results.txt @@ -0,0 +1,36 @@ +# spsc-wake-floor — swf5 verify bench evidence +# Date: 2026-06-11 · Host: OrbStack moon-dev VM (Ubuntu 24.04, aarch64, kernel 6.17) +# Baseline binary: commit 9861407 (task-1 quickwins, PRE wake-floor) — moon-baseline worktree +# Wakefloor binary: working tree (race2 tick+notify, drain-cap re-notify, direct reply race) +# Runtime under test: monoio release (MOON_NO_URING unset → io_uring), redis-benchmark 8.x +# Method: tmp/bench-swf.sh — fresh server per row, --appendonly no, -r 100000, n=100k +# (baseline c1 latency row used n=20k: at 562 rps a 100k run takes ~3 min) + +## swf5 — 4-shard single-client SET latency (cross-shard dispatch path), c=1 P=1 + avg p50 p95 p99 max rps +baseline 1.774 1.967 3.983 4.071 15.495 562 +wakefloor 0.027 0.031 0.055 0.071 2.391 12,786 + + EXIT CRITERION cross-shard p99 < 1ms: PASS (0.071 ms, 57x better than baseline) + The ~1.8ms baseline floor = the periodic-tick-only wake (reply swept on next 1ms + tick + dispatch alignment); event-driven Notify removes it entirely. + +## Throughput no-regression (A2 bench gate) — SET / GET rps +config baseline-SET wakefloor-SET baseline-GET wakefloor-GET +s1 P1 c50 138,313 225,734 107,066 255,102 +s1 P16 c50 704,225 1,265,823 1,333,333 2,127,660 +s4 P1 c50 29,351 147,929 185,529 175,747 +s4 P16 c50 314,465 1,470,588 1,785,714 2,127,660 +s4 c1 P1 562 12,786 187,617 175,131 + + Pipelined 4-shard (the A2 gate): SET +368%, GET +19% — NO regression. + 4-shard non-pipelined SET +404% (29K -> 148K): cross-shard writes no longer + wait for the tick. GET s4 P1/c1 within run-to-run VM noise (-5..-7%) — + expected: cross-shard READS take the shared-read fastpath (no SPSC message), + so the wake change does not touch their path. + Single-shard improvements (+63..+80%) come from the same loop no longer + oversleeping its own local work between ticks. + +## Notes +- All numbers from Linux VM per project rule (macOS numbers are dev-only). +- redis-benchmark progress lines stripped via tr '\r' '\n' (8.x gotcha). diff --git a/.add/tooling/add.py b/.add/tooling/add.py new file mode 100644 index 000000000..ccb70c541 --- /dev/null +++ b/.add/tooling/add.py @@ -0,0 +1,2899 @@ +#!/usr/bin/env python3 +"""ADD — minimal scaffolder + state tracker for AI-Driven Development. + +One file = one task. This tool generates the per-task TASK.md (which Claude fills +in step by step) and maintains .add/state.json so any fresh session can resume +with `add.py status` instead of re-reading the whole repo. That is the anti- +context-rot core of the ADD method. + +Stdlib only. Writes are atomic (temp + os.replace) and refuse to clobber +existing artifacts unless --force is given. +""" +from __future__ import annotations + +import argparse +import getpass +import json +import os +import re +import sys +import tempfile +from datetime import date, datetime, timezone +from pathlib import Path + +# --- constants --------------------------------------------------------------- + +ROOT_DIRNAME = ".add" +STATE_FILE = "state.json" +MILESTONE_FILE = "MILESTONE.md" +# The project GOAL (v20) is read live from PROJECT.md — never copied into state.json +# (single-source; the foundation is the truth). A missing/blank source degrades to +# this sentinel so the read-only orientation surfaces never blank or crash. +GOAL_UNSET = "(unset — add a 'goal:' line to PROJECT.md)" +STAGES = ("prototype", "poc", "mvp", "production") +# v22 stage-graduation: the read-only cue `status` shows when the MVP is covered. +# Worded as the ACTION (never a file) so it stands before graduate.md exists. +GRADUATION_CUE = "MVP covered → propose graduation" +PHASES = ("specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done") +GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP") + + +def _phase_index(name: str) -> int: + """Ordinal of a phase in PHASES; used to enforce forward-skip rules.""" + return PHASES.index(name) + +# `add.py guide` copy: per-phase (concrete next action, book chapter to read). +# Keep the action wording aligned with each phase's EXIT line in the TASK template. +PHASE_GUIDE = { + "specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions lowest-confidence first and flag the biggest risk", + "03-step-1-specify.md"), + "scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable", + "04-step-2-scenarios.md"), + "contract": ("freeze the shape — signature, fields, error codes; names match the glossary", + "05-step-3-contract.md"), + "tests": ("write one failing test per scenario; run them RED for the right reason", + "06-step-4-tests.md"), + "build": ("write the minimum code to pass the tests; change no test and no contract", + "07-step-5-build.md"), + "verify": ("run the suite + non-functional checks, then record the gate", + "08-step-6-verify.md"), + "observe": ("note what to watch + the spec delta for the next loop", + "09-the-loop.md"), + "done": ("this task is done — pick the next feature", + "02-the-flow.md"), +} +# Phase -> who owns it, for the `--json` autonomy signal. An autonomous harness may run a +# phase only when owner=="ai" (stop is false); every other phase is a checkpoint. The map +# follows the book's who-does-what table (Verify is "human only"); `tests`/`build`/`observe` +# are AI-led. A phase missing here is `unmapped_phase` (fail closed) — never defaulted. +PHASE_OWNER = { + "specify": "human", "scenarios": "human", "contract": "seam", + "tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human", +} +SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist") + +# Guideline-injection targets + version-stable markers. NEVER change these marker +# strings: a re-run finds the old block by exact match, so changing them would +# orphan every block written by a prior version (see TASK guideline-inject). +GUIDELINE_FILES = ("AGENTS.md", "CLAUDE.md") +_GUIDE_BEGIN = "" +_GUIDE_END = "" + +# Minimal embedded fallback so the tool still works if templates/ is missing +# (circuit breaker: never hard-fail just because a template file was deleted). +_FALLBACK_TASK = """# TASK: {title} + +slug: {slug} · created: {date} · stage: {stage} +phase: specify + +## 1 · SPECIFY +Feature: +Framings weighed: +Must: +Reject: +After: +Assumptions — lowest-confidence first: + ⚠ — lowest confidence because ; if wrong: + +## 2 · SCENARIOS +## 3 · CONTRACT +Status: DRAFT +## 4 · TESTS +## 5 · BUILD +## 6 · VERIFY +### GATE RECORD +Outcome: +## 7 · OBSERVE +""" + + +# --- low-level IO (designed for failure: atomic, no silent clobber) ---------- + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _atomic_write(path: Path, text: str) -> None: + """Write via a temp file in the same dir, then atomically replace. + + Avoids a half-written file if the process dies mid-write. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + os.replace(tmp, path) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + + +def _templates_dir() -> Path: + return Path(__file__).resolve().parent / "templates" + + +def _render_template(name: str, **subs: str) -> str: + """Load templates/.tmpl and substitute {{key}} tokens. + + Falls back to a built-in minimal template only for TASK.md. + """ + tmpl = _templates_dir() / f"{name}.tmpl" + if tmpl.exists(): + text = tmpl.read_text(encoding="utf-8") + elif name == "TASK.md": + text = _FALLBACK_TASK.replace("{title}", "{{title}}").replace( + "{slug}", "{{slug}}").replace("{date}", "{{date}}").replace("{stage}", "{{stage}}") + else: + text = "" + for key, val in subs.items(): + text = text.replace("{{" + key + "}}", val) + return text + + +# --- state ------------------------------------------------------------------- + +def find_root(start: Path | None = None) -> Path | None: + """Walk up from cwd to find a .add/ project root.""" + cur = (start or Path.cwd()).resolve() + for d in (cur, *cur.parents): + if (d / ROOT_DIRNAME / STATE_FILE).exists(): + return d / ROOT_DIRNAME + return None + + +def _require_root() -> Path: + root = find_root() + if root is None: + _die("no .add/ project found. Run `add.py init` first.") + return root + + +def load_state(root: Path) -> dict: + """Load + parse state.json, failing CLOSED. A corrupt or unreadable state file + dies with a clean 'state_invalid' message (never a raw traceback), so every + command that loads state degrades gracefully (design-for-failure).""" + try: + return json.loads((root / STATE_FILE).read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + _die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable " + f"({e.__class__.__name__}) — restore it from git or a backup") + + +def _load_state_for_json() -> tuple[Path, dict]: + """Fail-closed state load for `--json` paths: a missing project or unparseable + state.json -> `no_state` on stderr + exit 1, with EMPTY stdout (never a partial + JSON object a harness might parse). Built from State only — reads no docs/ chapter.""" + root = find_root() + if root is None: + _die("no_state") + try: + return root, json.loads((root / STATE_FILE).read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + _die("no_state") + + +def _phase_owner(phase: str) -> str: + """Map a phase to its owner (human|seam|ai); `unmapped_phase` if absent (fail closed).""" + owner = PHASE_OWNER.get(phase) + if owner is None: + _die("unmapped_phase") + return owner + + +def save_state(root: Path, state: dict) -> None: + state["updated"] = _now() + _atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n") + + +def _setup_locked(state: dict) -> bool: + """True when the project's setup is locked — i.e. the build-boundary gate is OPEN. + + A state with NO "setup" key is GRANDFATHERED-locked: plain `init` and every legacy + project are never gated (the lock is opt-in via `init --await-lock`). The gate is + therefore active in exactly one case: "setup" present AND locked is False.""" + return ("setup" not in state) or (state["setup"].get("locked") is True) + + +def _die(msg: str, code: int = 1) -> None: + print(f"add: error: {msg}", file=sys.stderr) + raise SystemExit(code) + + +# --- guideline injection (dynamic-by-reference; designed for failure) -------- +# +# Inject one stable, marker-delimited ADD block into the project root's AGENTS.md +# and CLAUDE.md. The block is DYNAMIC-BY-REFERENCE: it tells the agent to run +# `add.py status` and read PROJECT.md — it never embeds live state (slug, phase, +# gate). Auto-updated context files measurably hurt (ETH-Zurich: ~3% lower success, +# 20%+ more cost), so the stable pointer is the whole point. + +def _guideline_block() -> str: + """The canonical ADD block (markers + body, no trailing newline). + + Agent-agnostic by design (v14 agent-portability): the routing steps depend + only on the CLI and plain files, so any agent — Claude, Cursor, Copilot, + Codex — can follow them. Claude additionally gets the `add` skill.""" + return ( + f"{_GUIDE_BEGIN}\n" + "## ADD — how to work in this repo\n" + "\n" + "This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build;\n" + "the human owns direction and verification. The loop below works for any agent —\n" + "Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code:\n" + "\n" + "1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n" + " next (the resume point; read it first every session).\n" + "2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n" + " builds on.\n" + "3. Run `python3 .add/tooling/add.py guide` — it names the phase and the exact\n" + " phase-guide file to read (the `guide :` line). Work ONLY that phase — each\n" + " guide ends with its exit gate and the command to move on.\n" + "\n" + "The flow: INTAKE sizes a request into a milestone; each task runs the\n" + "**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle,\n" + "ONE human approval at the frozen contract — then a self-driving build→verify\n" + "run. Non-negotiable for every agent:\n" + "Never weaken a test or edit a frozen contract to make a build pass; a security\n" + "finding is always HARD-STOP — never auto-passed.\n" + "\n" + "On Claude Code the `add` skill drives this loop automatically; other agents\n" + "follow the three steps. The book is in `.add/docs/`. This block is generated\n" + "by `add.py sync-guidelines`; edit outside the markers, not inside.\n" + f"{_GUIDE_END}" + ) + + +def _inject_block(path: Path) -> str: + """Write the ADD block into `path`. Returns created|updated|unchanged. + + - unchanged: on-disk block already matches -> no write, no .bak (idempotent). + - updated: existing content changes -> back up the original to .bak first. + - created: file did not exist -> write the block, no .bak. + User content outside the markers is always preserved. + """ + block = _guideline_block() + if path.exists(): + current = path.read_text(encoding="utf-8") + begin = current.find(_GUIDE_BEGIN) + if begin != -1: + end = current.find(_GUIDE_END, begin) + if end != -1: # replace only the marked region + end += len(_GUIDE_END) + new = current[:begin] + block + current[end:] + else: # begin without end: corrupt — append fresh + print(f"add: warning: {path.name}: found an ADD:BEGIN with no ADD:END " + "— appending a fresh block; review the result", file=sys.stderr) + new = current.rstrip("\n") + "\n\n" + block + "\n" + else: # no block yet — append, keep user content + new = current.rstrip("\n") + "\n\n" + block + "\n" + if new == current: + return "unchanged" + _atomic_write(Path(str(path) + ".bak"), current) # rollback path before mutate + _atomic_write(path, new) + return "updated" + _atomic_write(path, block + "\n") + return "created" + + +def _inject_guidelines(project_root: Path) -> list[tuple[str, str]]: + """Inject the block into each guideline file under `project_root`. + + Symlink-dedup: targets resolving (os.path.realpath) to the same inode are + written once, against the REAL file (never replacing the symlink with a + regular file). Per-target OSError is isolated (warn+skip) so one unwritable + file never aborts the run or `init`. + """ + results: list[tuple[str, str]] = [] + seen: set[str] = set() + for name in GUIDELINE_FILES: + target = project_root / name + real = os.path.realpath(target) + if real in seen: + continue + seen.add(real) + write_target = Path(real) if target.is_symlink() else target + try: + action = _inject_block(write_target) + except (OSError, UnicodeDecodeError) as exc: + # design for failure: an unwritable target OR a non-UTF-8 existing file + # (e.g. a UTF-16 CLAUDE.md from a Windows editor) must not crash init or + # abort the other target — warn and skip this one. + print(f"add: warning: could not sync {name} — {exc}; skipped", + file=sys.stderr) + action = "skipped" + results.append((name, action)) + return results + + +# --- commands ---------------------------------------------------------------- + +_INIT_EXCLUDE = { + ".add", "AGENTS.md", "CLAUDE.md", ".git", + ".gitignore", ".gitattributes", ".github", ".editorconfig", # VCS/CI/editor scaffolding — no domain signal + "LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", # legal boilerplate — no domain signal +} # README/docs/source are NOT excluded: they carry domain content adopt.md maps -> brownfield + + +def _is_brownfield(base: Path) -> bool: + """True when `base` already holds project content beyond the tool's own scaffolding. + + Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the + autonomous-onboarding flow knows to map existing code into the living documentation. INTERPRETING + that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals.""" + if not base.is_dir(): + return False + return any(child.name not in _INIT_EXCLUDE for child in base.iterdir()) + + +def cmd_init(args: argparse.Namespace) -> None: + base = Path(args.dir).resolve() + root = base / ROOT_DIRNAME + state_path = root / STATE_FILE + if state_path.exists() and not args.force: + _die(f"already initialised at {root} (use --force to reset state)") + + (root / "tasks").mkdir(parents=True, exist_ok=True) + today = date.today().isoformat() + proj_name = args.name or base.name + + # survivor-layer files — never clobber an existing one, never write a blank one + for fname in SETUP_FILES: + dest = root / fname + if dest.exists(): + continue + rendered = _render_template(fname, date=today, project=proj_name, stage=args.stage) + if not rendered.strip(): + # A missing/stale template rendered to nothing. Skip rather than create + # a 0-content survivor file (design-for-failure; circuit breaker so an + # upgrade with a stale templates/ dir can't silently produce empty docs). + print(f"add: warning: template for {fname} is missing/blank — skipped", + file=sys.stderr) + continue + _atomic_write(dest, rendered) + + state = { + "project": proj_name, + "stage": args.stage, + "active_task": None, + "active_milestone": None, + "tasks": {}, + "milestones": {}, + "created": _now(), + "updated": _now(), + } + if getattr(args, "await_lock", False): + # opt-in: seed an UNLOCKED setup so the build-boundary gate is active until + # `add.py lock`. Plain init omits this key entirely (grandfathered-locked). + state["setup"] = {"locked": False, "locked_at": None, "locked_by": None, "layers": []} + save_state(root, state) + # zero-config: give any agent a stable pointer into the ADD runtime. + for name, action in _inject_guidelines(base): + if action != "unchanged": + print(f"{action:>9} {name}") + print(f"initialised ADD project '{state['project']}' (stage: {state['stage']}) at {root}") + if _is_brownfield(base): + # Existing code present — the AI maps it SILENTLY into the survivors (skill/add/adopt.md), + # then the human locks it down. The engine only flags it; it never reads or fills the code. + print("brownfield: existing code detected — the `add` skill maps it into your") + print(" foundation (silent), then you lock it down: add.py lock") + else: + print("next: open Claude Code, run `/add`, and say what you want to build —") + print(" the `add` skill sizes it into a milestone and drives the build with you.") + + +def cmd_sync_guidelines(args: argparse.Namespace) -> None: + project_root = _require_root().parent + for name, action in _inject_guidelines(project_root): + print(f"{action:>9} {name}") + + +def cmd_new_task(args: argparse.Namespace) -> None: + root = _require_root() + state = load_state(root) + # build-boundary gate: pre-lock, EXACTLY one first task may be drafted; refuse a 2nd. + if not _setup_locked(state) and state.get("tasks"): + _die("setup_unlocked: lock the foundation first — add.py lock") + slug = args.slug + if not slug.replace("-", "").replace("_", "").isalnum(): + _die("slug must be alphanumeric with - or _ only") + tdir = root / "tasks" / slug + task_md = tdir / "TASK.md" + if task_md.exists() and not args.force: + _die(f"task '{slug}' already exists (use --force to overwrite TASK.md)") + + # link to a milestone (explicit, or the active one) — validate before any write + milestone = getattr(args, "milestone", None) or state.get("active_milestone") + if milestone and milestone not in state.get("milestones", {}): + _die("unknown_milestone") + depends_on = _parse_deps(getattr(args, "depends_on", None)) + + (tdir / "tests").mkdir(parents=True, exist_ok=True) + (tdir / "src").mkdir(parents=True, exist_ok=True) + title = args.title or slug.replace("-", " ").replace("_", " ").title() + _atomic_write(task_md, _render_template( + "TASK.md", title=title, slug=slug, date=date.today().isoformat(), stage=state["stage"])) + + state["tasks"][slug] = { + "title": title, + "phase": "specify", + "gate": "none", + "milestone": milestone, + "depends_on": depends_on, + "created": _now(), + "updated": _now(), + } + state["active_task"] = slug + save_state(root, state) + print(f"created task '{slug}' -> {task_md}") + if milestone: + print(f"linked to milestone '{milestone}'" + + (f", depends-on {depends_on}" if depends_on else "")) + else: + # warn-never-block: the task is created (escape hatch), but nudge back toward the + # intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act. + print(f"note: '{slug}' is not attached to a milestone — size it via /add (intake), " + "or pass --milestone ") + print("active task set. phase: specify. Fill section 1 (SPECIFY), then: add.py advance") + + +def _parse_deps(raw: str | None) -> list[str]: + if not raw: + return [] + return [d.strip() for d in raw.split(",") if d.strip()] + + +def _task_done(t: dict) -> bool: + # Matrix 3: a task is done when Verify reads PASS *or a signed RISK-ACCEPTED*. + # Both completing gates advance phase to "done" (cmd_gate), and a waiver is + # signed at gate time — so a verdict gate is enough here; we need not re-read + # the waiver. HARD-STOP never reaches "done". A bare `phase done` (escape + # hatch, gate still "none") deliberately does NOT count: completion needs a + # recorded verdict, not just a phase marker. + return t.get("phase") == "done" and t.get("gate") in ("PASS", "RISK-ACCEPTED") + + +def _archived_task_slugs(state: dict) -> set[str]: + """Slugs of tasks that left active state via archive — all were PASS-done at + archive time, so a dep on one of them counts as satisfied (not dangling). + + INVARIANT: this is sound only because cmd_archive_milestone REFUSES to archive a + milestone with an incomplete member. Any NEW task-removal path (un-archive/restore, + heavy archive) MUST preserve "archived ⇒ was PASS-done" or `ready` will green-light + a task whose dependency never completed.""" + out: set[str] = set() + for rec in state.get("archived", []): + out.update(rec.get("task_slugs", [])) # .get: pre-v2 records have none + return out + + +def _resolve_task(state: dict, slug: str | None) -> str: + slug = slug or state.get("active_task") + if not slug: + _die("no task specified and no active task set") + if slug not in state["tasks"]: + _die(f"unknown task '{slug}'") + return slug + + +def cmd_phase(args: argparse.Namespace) -> None: + root = _require_root() + state = load_state(root) + slug = _resolve_task(state, args.slug) + if args.phase not in PHASES: + _die(f"phase must be one of: {', '.join(PHASES)}") + state["tasks"][slug]["phase"] = args.phase + state["tasks"][slug]["updated"] = _now() + _sync_task_marker(root, slug, args.phase) + save_state(root, state) + print(f"task '{slug}' phase -> {args.phase}") + + +def cmd_advance(args: argparse.Namespace) -> None: + root = _require_root() + state = load_state(root) + slug = _resolve_task(state, args.slug) + cur = state["tasks"][slug]["phase"] + idx = PHASES.index(cur) + if idx >= len(PHASES) - 1: + _die(f"task '{slug}' already at final phase ({cur})") + nxt = PHASES[idx + 1] + # build-boundary gate: pre-lock the front (specify..tests) is allowed, but crossing + # into build/verify/observe/done is refused until `add.py lock`. + if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"): + _die("setup_unlocked: lock the foundation first — add.py lock") + # flag-first freeze guard (task unflagged-freeze): a FROZEN §3 may not cross + # into build without a WELL-FORMED lowest-confidence flag. On pass, stamp the + # verified marker so `audit` enforces the flag on THIS record only (open/new + # freezes — the unmarked predecessors are never retro-redded). REFUSE writes + # nothing (fail-closed); below the build boundary the flag is never checked. + if nxt == "build": + raw3 = _raw_phase_bodies(root, slug).get(3, "") + if _contract_frozen(raw3): + if not _flag_well_formed(raw3): + _die("unflagged_freeze: a frozen §3 must surface a well-formed " + "'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag " + "+ substantive content; bare 'none' only as 'none material — " + "biggest risk: X') before crossing into build") + state["tasks"][slug]["flag_verified"] = True + state["tasks"][slug]["phase"] = nxt + state["tasks"][slug]["updated"] = _now() + _sync_task_marker(root, slug, nxt) + save_state(root, state) + print(f"task '{slug}' phase {cur} -> {nxt}") + + +# The mechanized high-risk guard (run.md, v14): judging WHAT is high-risk stays +# human — a scope declares `risk: high` in its TASK.md header at the freeze. The +# engine then enforces the pure token contradiction: risk: high WITHOUT +# autonomy: conservative is unguarded, and completion is refused. Tokens are +# read from the header region (text before the first section heading) with HTML +# comments stripped — a documentation comment is never a declaration. +_RISK_HIGH_RE = re.compile(r"\brisk:\s*high\b") +_AUTONOMY_CONSERVATIVE_RE = re.compile(r"\bautonomy:\s*conservative\b") + + +def _task_header(root: Path, slug: str) -> str: + """The TASK.md header region — where declared tokens (risk · autonomy) + live — with HTML comments stripped. Missing file -> '' (no tokens).""" + try: + text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8") + except OSError: + return "" + return re.sub(r"", "", text.split("\n## ", 1)[0], flags=re.S) + + +def cmd_gate(args: argparse.Namespace) -> None: + root = _require_root() + state = load_state(root) + slug = _resolve_task(state, args.slug) + # build-boundary gate: no verdict may be recorded before the setup is locked. + if not _setup_locked(state): + _die("setup_unlocked: lock the foundation first — add.py lock") + if args.outcome not in GATES: + _die(f"outcome must be one of: {', '.join(GATES)}") + # Completing outcomes (PASS, RISK-ACCEPTED) are the VERIFY step's verdict, so they + # share the verify-phase guard — no silent skips (principle 7). HARD-STOP stays + # recordable from any phase (a security finding is always HARD-STOP). The + # deliberate, logged override is `add.py phase verify `. + completing = args.outcome in ("PASS", "RISK-ACCEPTED") + if completing: + current = state["tasks"][slug]["phase"] + if _phase_index(current) < _phase_index("verify"): + code = ("gate_pass_before_verify" if args.outcome == "PASS" + else "gate_risk_accepted_before_verify") + _die(f"{code}: task '{slug}' is at '{current}'; reach the verify phase " + f"first (or `add.py phase verify {slug}` to override)") + # the mechanized high-risk guard: an unguarded high-risk header refuses + # COMPLETION (PASS / RISK-ACCEPTED) until the dial is lowered and a human + # owns the gate. HARD-STOP is never blocked — stopping is always allowed. + hdr = _task_header(root, slug) + if _RISK_HIGH_RE.search(hdr) and not _AUTONOMY_CONSERVATIVE_RE.search(hdr): + _die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high " + "without autonomy: conservative — lower the autonomy level in the TASK.md " + "header; a human must own a high-risk gate (run.md guard)") + if args.outcome == "RISK-ACCEPTED": + # A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state + # so a later `check` can read/expire it. Refuse a partial waiver outright. + missing = [f for f in ("owner", "ticket", "expires") if not getattr(args, f)] + if missing: + _die("waiver_incomplete: RISK-ACCEPTED is a signed waiver; supply " + + ", ".join("--" + m for m in missing)) + state["tasks"][slug]["waiver"] = { + "owner": args.owner, "ticket": args.ticket, "expires": args.expires, + } + if completing: + state["tasks"][slug]["phase"] = "done" + _sync_task_marker(root, slug, "done") + state["tasks"][slug]["gate"] = args.outcome + state["tasks"][slug]["updated"] = _now() + save_state(root, state) + print(f"task '{slug}' gate -> {args.outcome}") + if args.outcome == "HARD-STOP": + print("HARD-STOP recorded: return to BUILD; nothing ships on a failing/security gate.") + + +def cmd_reopen(args: argparse.Namespace) -> None: + """Return an already-`done` task to an earlier phase with a never-silent record. + + The flow already permits backward correction (book ch02: "any phase may return + to an earlier one"); `done` is terminal EXCEPT via this recorded action. reopen + sets the phase back, resets the gate to "none" (the task must re-earn its + verdict), and appends an append-only `reopens` entry recording WHY. A done task + done via RISK-ACCEPTED carries a live `waiver`; reopen records it inside the entry + (prior_gate / prior_waiver) and drops the live key, so no signed waiver lingers + without a verdict. Judgement of WHEN to reopen stays the resolver's; the engine + only enforces the recorded, coherent transition. + """ + root = _require_root() + state = load_state(root) + slug = _resolve_task(state, args.slug) + t = state["tasks"][slug] + if t.get("phase") != "done": + _die(f"reopen_not_done: task '{slug}' is at '{t.get('phase')}', not done — " + "backward correction inside a live run is `add.py phase` / HARD-STOP, not reopen") + reason = (args.reason or "").strip() + if not reason: + _die("reopen_reason_required: reopen records WHY — supply a non-empty --reason") + target = args.to + if target not in PHASES[:7]: # specify..observe; never "done", never an unknown name + _die(f"reopen_target_invalid: --to must be one of {', '.join(PHASES[:7])} (got {target!r})") + now = _now() + entry = {"from": "done", "to": target, "reason": reason, "at": now, + "prior_gate": t.get("gate", "none")} + if t.get("waiver"): # void verdict's waiver -> history, drop the live key + entry["prior_waiver"] = t.pop("waiver") + t.setdefault("reopens", []).append(entry) + t["phase"] = target + t["gate"] = "none" + t["updated"] = now + _sync_task_marker(root, slug, target) + save_state(root, state) + print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none") + + +def cmd_lock(args: argparse.Namespace) -> None: + """The human baseline approval: freeze the autonomously-drafted setup in ONE atomic write. + + Setup-level analog of the contract freeze — the only new human action onboarding + needs. `add.py lock` is judgment-free (it records the signature; it does NOT inspect + the artifacts): the human's signature IS the gate.""" + root = _require_root() + state = load_state(root) + # idempotent-guarded: the predicate also treats a grandfathered (no "setup" key) + # project as already locked, so a bare re-lock there refuses too. + if _setup_locked(state) and not args.force: + _die("already_locked: setup is already locked (use --force to re-lock)") + # parse layers BEFORE any write so an invalid request never half-locks (design-for-failure). + raw = args.layers if args.layers is not None else "foundation,scope,contract" + layers = [s.strip() for s in raw.split(",") if s.strip()] + if not layers: + _die("layers_invalid: --layers must name at least one lock layer") + who = args.by or getpass.getuser() + when = _now() + # ONE atomic write — no partial lock state. + state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers} + save_state(root, state) + if getattr(args, "json", False): + print(json.dumps( + {"locked": True, "locked_at": when, "locked_by": who, "layers": layers}, + separators=(",", ":"))) + else: + print(f"locked setup ({','.join(layers)}) by {who} @ {when}") + + +def _has_production_roadmap(state: dict) -> bool: + """True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC). + The single source of the stage-graduation floor (v22 graduate-guide): the guard counts + that a production-roadmap RECORD exists — it never judges whether those milestones are + done/good/sufficient (gather-not-judge). An archived-out-of-state roadmap falls to --force.""" + return any(m.get("stage") == "production" + for m in state.get("milestones", {}).values()) + + +def cmd_stage(args: argparse.Namespace) -> None: + root = _require_root() + state = load_state(root) + if args.stage not in STAGES: + _die(f"stage must be one of: {', '.join(STAGES)}") + # v22 stage-graduation guard: the →production TRANSITION refuses without a roadmap — a tally + # check (≥1 production milestone exists), never a readiness judgment. Scoped to production + # ONLY; every other flip is the existing bare flip, byte-unchanged. --force overrides + # (precedent: lock --force). The flip is graduate.md's FINAL, confirmed-roadmap step. + forced = getattr(args, "force", False) + bypassing = False + if args.stage == "production": + roadmap = _has_production_roadmap(state) + if not roadmap and not forced: + _die("stage_no_roadmap: no production milestone drafted. Draft ≥1 via " + "graduate.md (new-milestone --stage production), or use --force to override.") + bypassing = forced and not roadmap + state["stage"] = args.stage + save_state(root, state) + print(f"project stage -> {args.stage}") + if bypassing: + print("(--force: bypassed roadmap check — no production milestone drafted)") + + +def cmd_status(args: argparse.Namespace) -> None: + if getattr(args, "json", False): + root, state = _load_state_for_json() + tasks = state.get("tasks") or {} + milestones = state.get("milestones") or {} + ms_list = [] + for mslug, m in milestones.items(): + members = [t for t in tasks.values() if t.get("milestone") == mslug] + ms_list.append({"slug": mslug, "status": m.get("status", "active"), + "done": sum(1 for t in members if _task_done(t)), + "total": len(members)}) + grad_ready, grad_met, grad_total = _graduation_ready(root, state) + print(json.dumps({ + "project": state.get("project"), "stage": state.get("stage"), + "active_task": state.get("active_task"), + "milestones": ms_list, + "tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"), + "milestone": t.get("milestone")} for s, t in tasks.items()], + "graduation_ready": grad_ready, + "stage_criteria": {"met": grad_met, "total": grad_total}})) + return + root = _require_root() + state = load_state(root) + active = state.get("active_task") + tasks = state.get("tasks", {}) + # Compute once: True when setup is present AND locked is False (the lock-gate window). + # Reuses the canonical helper — do NOT write a parallel predicate. + unlocked = not _setup_locked(state) + print(f"project : {state.get('project', '(unknown)')}") + print(f"stage : {state.get('stage', '(unknown)')}") + # project GOAL + active-milestone goal (v20) — the loop's orientation anchor, read + # LIVE from PROJECT.md / MILESTONE.md (never state.json). Additive: every existing + # line stays put. A missing source degrades to a sentinel — one never blanks the other. + print(f"goal : {_project_goal(root)}") + _active_ms = state.get("active_milestone") + if _active_ms: + print(f"m-goal : {_milestone_doc(root, _active_ms)[1]} (← {_active_ms})") + # foundation pointer — read the cross-milestone context first (anti-rot) + if (root / "PROJECT.md").exists(): + print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)") + # wave resume hint — a live ledger outranks memory (streams.md "Wave ledger"). + # Existence-only: no open/read/parse, so the hint adds no IO failure path; a + # non-file at the path is not a ledger. One line PER live ledger — more than + # one live wave is an anomaly the orchestrator must see, never a line we hide. + for _wave in sorted((root / "milestones").glob("*/WAVE.md")): + if _wave.is_file(): + print(f"wave : LIVE — .add/milestones/{_wave.parent.name}/WAVE.md" + " (wave resume point — re-orient from the ledger first)") + + # milestone rollup (only when milestones are in use) + milestones = state.get("milestones") or {} + active_ms = state.get("active_milestone") + if milestones: + print("milestones:") + for mslug, m in milestones.items(): + members = [t for t in tasks.values() if t.get("milestone") == mslug] + done = sum(1 for t in members if _task_done(t)) + mark = "*" if mslug == active_ms else " " + print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done" + f" status={m.get('status', 'active')}") + # graduation cue (v22): project-global + read-only. Fires only when every milestone + # is done AND the human's PROJECT.md stage-goal-criteria are all checked — additive + # (a new line solely when ready; the non-ready output is byte-identical to before). + grad_ready, _gm, _gt = _graduation_ready(root, state) + if grad_ready: + print(f" → {GRADUATION_CUE}") + + # archived rollup — one line keeps state visible without re-bloating status + archived = state.get("archived") or [] + if archived: + n = len(archived) + m_tasks = sum(rec.get("tasks", 0) for rec in archived) + print(f"archived: {n} milestone{'s' if n != 1 else ''} " + f"({m_tasks} task{'s' if m_tasks != 1 else ''})") + + print(f"active : {active or '(none)'}") + if not tasks: + # First-run panel: a brand-new project's status is the moment a user is most + # lost. When the setup is unlocked, the only correct next move is review+lock — + # suppress the generic /add hint and name the two steps that matter. + print("tasks : (none yet)") + print() + if unlocked: + print("setup : UNLOCKED — review .add/SETUP-REVIEW.md (lowest-confidence first)," + " then sign: add.py lock") + print(" (the build-boundary gate is closed until the foundation is locked)") + else: + print("next : you're set up. In Claude Code, run /add and say what you want to") + print(" build — the `add` skill sizes it into a milestone and drives the") + print(' build with you. Escape hatch: add.py new-task --title "..."') + return + print("tasks :") + for slug, t in tasks.items(): + mark = "*" if slug == active else " " + deps = t.get("depends_on") or [] + dep_s = f" deps={','.join(deps)}" if deps else "" + ms_s = f" [{t['milestone']}]" if t.get("milestone") else "" + print(f" {mark} {slug:<24} phase={t['phase']:<10} gate={t['gate']}{ms_s}{dep_s}") + # fold-pressure nudge: surface unfolded competency deltas so emission can't + # silently outrun the human fold (read-only; v11). Silent when none are open. + open_deltas = sum(len(v) for v in _collect_open_deltas(root).values()) + if open_deltas: + print(f"deltas : {open_deltas} open — consolidate at milestone close (add.py deltas)") + # When the setup is unlocked, the only terminal guidance that matters is + # review+lock; suppress the generic resume block so it does not compete. + if unlocked: + print("\nsetup : UNLOCKED — review .add/SETUP-REVIEW.md (lowest-confidence first)," + " then sign: add.py lock") + print(" (the build-boundary gate is closed until the foundation is locked)") + elif active and active in tasks: + ph = tasks[active]["phase"] + if ph == "done": + print(f"\nresume : task '{active}' is done ({tasks[active]['gate']}).") + print(" start the next feature: add.py new-task ") + else: + print(f"\nresume : task '{active}' is at phase '{ph}'.") + print(f" read .add/tasks/{active}/TASK.md and continue that phase.") + + +# Agent-portability (v14): `guide` names the PHASE PLAYBOOK file — the same +# guides the Claude skill loads, installed as plain markdown by every channel +# at .claude/skills/add/phases/ — so ANY agent (Cursor, Copilot, Codex) can be +# routed there through the CLI alone. Never a dead pointer: the path is printed +# only if the file exists; a missing tree gets an install hint instead. +_PHASE_GUIDE_FILES = { + "specify": "1-specify.md", "scenarios": "2-scenarios.md", + "contract": "3-contract.md", "tests": "4-tests.md", + "build": "5-build.md", "verify": "6-verify.md", "observe": "7-observe.md", +} +_SKILL_PHASES_DIR = Path(".claude") / "skills" / "add" / "phases" + + +def _phase_guide_path(project_root: Path, phase: str) -> str | None: + """Relative path to the phase playbook if it exists, else None. + done/unknown phases have no playbook (the `then:` line routes onward).""" + fname = _PHASE_GUIDE_FILES.get(phase) + if fname is None: + return None + rel = _SKILL_PHASES_DIR / fname + return str(rel) if (project_root / rel).is_file() else None + + +def cmd_guide(args: argparse.Namespace) -> None: + """Answer "what do I do next?" for the active (or named) task. + + Strictly read-only: load_state only — never save_state, never writes a TASK.md. + """ + if getattr(args, "json", False): + json_root, state = _load_state_for_json() + slug = args.slug or state.get("active_task") + if not slug: + print(json.dumps({"task": None, "phase": None, "owner": "human", "stop": True, + "next_step": "start your first feature -> add.py new-task ", + "chapter": ".add/docs/02-the-flow.md", "gate": None, + "guide": None})) + return + t = (state.get("tasks") or {}).get(slug) + if t is None: + _die(f"unknown task '{slug}'") + phase = t.get("phase") + owner = _phase_owner(phase) # _die unmapped_phase before any stdout + action, chapter = PHASE_GUIDE[phase] # phase is mapped, so PHASE_GUIDE has it too + print(json.dumps({"task": slug, "phase": phase, "owner": owner, + "stop": owner != "ai", "next_step": action, + "chapter": f".add/docs/{chapter}", "gate": t.get("gate"), + "guide": _phase_guide_path(json_root.parent, phase)})) + return + root = _require_root() + state = load_state(root) + slug = args.slug or state.get("active_task") + if not slug: + print("active : (none)") + print('next : start your first feature -> add.py new-task --title "..."') + print("read : .add/docs/02-the-flow.md") + return + if slug not in state.get("tasks", {}): + _die(f"unknown task '{slug}'") + phase = state["tasks"][slug]["phase"] + entry = PHASE_GUIDE.get(phase) + if entry is None: # corrupted/hand-edited state.json — fail clean, not KeyError + _die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)") + action, chapter = entry + print(f"active : {slug} (phase: {phase})") + print(f"goal : {_project_goal(root)}") # v20 — the next-step surface still shows what the work is FOR + print(f"next : {action}") + print(f"read : .add/docs/{chapter}") + gp = _phase_guide_path(root.parent, phase) + if gp is not None: + print(f"guide : {gp}") + elif phase in _PHASE_GUIDE_FILES: + print("guide : (phase guides not installed — npx @pilotspace/add init)") + if phase == "verify": + print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP") + elif phase == "done": + print("then : start the next feature -> add.py new-task ") + else: + print("then : add.py advance") + + +def _read_task_phase(root: Path, slug: str) -> str | None: + """Read the `phase:` marker from a task's TASK.md, or None if absent.""" + task_md = root / "tasks" / slug / "TASK.md" + if not task_md.exists(): + return None + for line in task_md.read_text(encoding="utf-8").splitlines(): + if line.startswith("phase:"): + rest = line[len("phase:"):].strip() + return rest.split()[0] if rest else None + return None + + +def cmd_check(args: argparse.Namespace) -> None: + """Read-only integrity check of the .add project. Exit 1 if anything fails.""" + as_json = getattr(args, "json", False) + if as_json: + root, state = _load_state_for_json() # fail closed -> no_state + empty stdout + else: + root = find_root() + if root is None: + _die("no_project") + try: + state = json.loads((root / STATE_FILE).read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + _die("state_invalid") + + checks: list[tuple[bool, str, str]] = [] # (ok, description, reason-if-failed) + for key in ("project", "stage", "active_task", "tasks"): + checks.append((key in state, f"state has key '{key}'", "missing")) + + tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {} + milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {} + archived_slugs = _archived_task_slugs(state) # archived deps still resolve + warnings: list[tuple[str, str]] = [] # (name, reason) — nudges that NEVER feed `failed` + for slug, t in tasks.items(): + task_md = root / "tasks" / slug / "TASK.md" + checks.append((task_md.exists(), f"task '{slug}' has TASK.md", "file missing")) + marker, want = _read_task_phase(root, slug), t.get("phase") + checks.append((marker == want, f"task '{slug}' marker matches state", + f"marker={marker!r} state={want!r}")) + # drift: milestone + dependency references must resolve + ms = t.get("milestone") + if ms is not None: + checks.append((ms in milestones, f"task '{slug}' milestone resolves", + f"unknown milestone {ms!r}")) + else: + # warn-never-block: a task outside a milestone is a structural nudge back toward + # the intake flow — NOT a failure. Names structure, never the act of intake. + warnings.append((f"task '{slug}'", "is outside a milestone — size it via the /add " + "intake flow (or attach with --milestone)")) + for dep in t.get("depends_on") or []: + checks.append((dep in tasks or dep in archived_slugs, + f"task '{slug}' dep '{dep}' resolves", "unknown task")) + # waiver expiry (Matrix 4): a RISK-ACCEPTED waiver whose `expires` has passed is + # stale — the gate stored it; `check` is the standing monitor that catches the lapse. + # Fail-closed: a missing/unparseable expires is a FAIL, never a silent pass. + if t.get("gate") == "RISK-ACCEPTED": + exp = (t.get("waiver") or {}).get("expires") + try: + ok = exp is not None and date.fromisoformat(exp) >= date.today() + reason = f"waiver_expired (expires={exp})" + except (ValueError, TypeError): + ok, reason = False, f"waiver_expired (unparseable expires={exp!r})" + checks.append((ok, f"task '{slug}' waiver not expired", reason)) + # delta-lint: validate all OPEN entries in the "### Competency deltas" block. + # Fail-closed; folded/rejected entries are skipped (open-only). Only emits a + # check when at least one delta-attempt is present in the block. + lint_result = _lint_task_deltas(root, slug) + if lint_result is not None: + ok, reason = lint_result + checks.append((ok, f"task '{slug}' deltas well-formed", reason)) + + # drift: a done milestone must have no unfinished tasks + for mslug, m in milestones.items(): + if m.get("status") == "done": + unfinished = [s for s, t in tasks.items() + if t.get("milestone") == mslug and not _task_done(t)] + checks.append((not unfinished, f"done milestone '{mslug}' fully complete", + f"unfinished: {unfinished}")) + + # dependency graph must be acyclic + cycle = _find_cycle(tasks) + checks.append((cycle is None, "task dependencies are acyclic", + f"cycle: {' -> '.join(cycle)}" if cycle else "")) + + passed = sum(1 for ok, _, _ in checks if ok) + failed = len(checks) - passed + if as_json: + print(json.dumps({"passed": passed, "failed": failed, + "warned": len(warnings), + "warnings": [{"name": name, "reason": reason} + for name, reason in warnings], + "checks": [{"ok": ok, "name": desc, + "reason": reason if not ok else ""} + for ok, desc, reason in checks]})) + else: + for ok, desc, reason in checks: + print(f"PASS {desc}" if ok else f"FAIL {desc}: {reason}") + for name, reason in warnings: + print(f"WARN {name} {reason}") + summary = f"check: {passed} passed, {failed} failed" + if warnings: + summary += f" ({len(warnings)} warnings)" # frozen §3: summary gains "(N warnings)" + print(summary) + if failed: + raise SystemExit(1) + + +def cmd_new_milestone(args: argparse.Namespace) -> None: + root = _require_root() + state = load_state(root) + slug = args.slug + if not slug.replace("-", "").replace("_", "").isalnum(): + _die("bad_slug") + state.setdefault("milestones", {}) + mdir = root / "milestones" / slug + mfile = mdir / MILESTONE_FILE + if mfile.exists() and not args.force: + _die("milestone_exists") + mdir.mkdir(parents=True, exist_ok=True) + title = args.title or slug.replace("-", " ").replace("_", " ").title() + _atomic_write(mfile, _render_template( + "MILESTONE.md", title=title, goal=args.goal or "", + stage=args.stage, date=date.today().isoformat())) + state["milestones"][slug] = { + "title": title, "goal": args.goal or "", "stage": args.stage, + "status": "active", "created": _now(), "updated": _now(), + } + state["active_milestone"] = slug + save_state(root, state) + print(f"created milestone '{slug}' -> {mfile}") + print(f"active milestone set. Decompose it into tasks: add.py new-task --depends-on ...") + + +def cmd_ready(args: argparse.Namespace) -> None: + if getattr(args, "json", False): + _, state = _load_state_for_json() + tasks = state.get("tasks") or {} + archived = _archived_task_slugs(state) + + def _ok(d: str) -> bool: + return d in archived or (d in tasks and _task_done(tasks[d])) + + ready, blocked = [], [] + for slug, t in tasks.items(): + if _task_done(t): + continue + unmet = [d for d in (t.get("depends_on") or []) if not _ok(d)] + (blocked.append({"slug": slug, "waiting_on": unmet}) + if unmet else ready.append(slug)) + print(json.dumps({"ready": ready, "blocked": blocked})) + return + root = _require_root() + state = load_state(root) + tasks = state.get("tasks", {}) + archived_slugs = _archived_task_slugs(state) # an archived dep was PASS-done + + def _dep_satisfied(d: str) -> bool: + if d in archived_slugs: + return True # archived ⇒ complete when archived + return d in tasks and _task_done(tasks[d]) # in-state dep must be done; else blocked + + ready = [] + for slug, t in tasks.items(): + if _task_done(t): + continue + deps = t.get("depends_on") or [] + if all(_dep_satisfied(d) for d in deps): + ready.append(slug) + if not ready: + print("ready: (none — all tasks are done or blocked)") + return + print("ready to start (deps satisfied):") + for slug in ready: + deps = tasks[slug].get("depends_on") or [] + suffix = f" (after {', '.join(deps)})" if deps else "" + print(f" {slug}{suffix}") + + +def cmd_milestone_done(args: argparse.Namespace) -> None: + root = _require_root() + state = load_state(root) + slug = args.slug + if slug not in state.get("milestones", {}): + _die("unknown_milestone") + members = {s: t for s, t in state.get("tasks", {}).items() if t.get("milestone") == slug} + blockers = [s for s, t in members.items() if not _task_done(t)] + if not members: + _die("milestone_incomplete") # nothing attached -> nothing proven + if blockers: + print(f"milestone '{slug}' has unfinished tasks:", file=sys.stderr) + for s in blockers: + t = members[s] + print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr) + _die("milestone_incomplete") + # Goal-gate (v20 dynamic-task-loop): a milestone holds until its exit criteria are + # met. The engine READS the checkbox tally (the human's goal-met affirmation, like a + # gate=PASS) — it never judges the goal. Fires ONLY when criteria exist, so a + # criteria-less milestone and every pre-v20 close path stay valid. milestone-done is + # the SOLE status->done transition; archive-milestone/compact already refuse a + # non-done milestone, so this single gate has no back door. Refuse BEFORE any write. + met, total = _exit_criteria(root, slug) + if total > 0 and met < total: + _die(f"milestone_goal_unmet: milestone '{slug}' has {met}/{total} exit criteria met " + f"— check the remaining boxes in MILESTONE.md (the goal-gate holds the loop " + f"open) or propose the next tasks (add.py deltas)") + # Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the + # status flip, so a write failure rolls back naturally (status never commits -> + # no done-without-retro state). The retro step is read-only on state.json. + try: + retro_path = _write_retro(root, state, slug) + except OSError: + _die("retro_write_failed") + state["milestones"][slug]["status"] = "done" + state["milestones"][slug]["updated"] = _now() + save_state(root, state) + waived = [s for s, t in members.items() if t.get("gate") == "RISK-ACCEPTED"] + tail = f" ({len(waived)} via a signed RISK-ACCEPTED waiver)" if waived else "" + print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).") + print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)") + print("Confirm the MILESTONE.md exit criteria are checked, then archive/start the next.") + # fold-pressure nudge: milestone close is the natural fold point for open deltas (v11) + open_deltas = sum(len(v) for v in _collect_open_deltas(root).values()) + if open_deltas: + noun = "delta" if open_deltas == 1 else "deltas" + print(f"note: {open_deltas} open {noun} to consolidate into the foundation " + f"— review with: add.py deltas") + + +def cmd_archive_milestone(args: argparse.Namespace) -> None: + """Light archive: collapse a DONE milestone out of active state (files stay).""" + root = _require_root() + state = load_state(root) + slug = args.slug + # validate before any mutation — a reject must leave state.json byte-for-byte unchanged + if slug not in state.get("milestones", {}): + _die("unknown_milestone") + ms = state["milestones"][slug] + if ms.get("status") != "done": + _die("milestone_not_done") # run `add.py milestone-done` first; never lose live work + tasks = state.get("tasks", {}) + members = [s for s, t in tasks.items() if t.get("milestone") == slug] + # the status flag can go stale (a task attached AFTER milestone-done is still + # live); re-check now so archive can never silently delete unfinished work. + incomplete = [s for s in members if not _task_done(tasks[s])] + if incomplete: + print(f"milestone '{slug}' has live unfinished tasks:", file=sys.stderr) + for s in incomplete: + t = tasks[s] + print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr) + _die("milestone_has_incomplete_tasks") + # pre-archive snapshot (design-for-failure): the archived record below keeps only a + # slug-list, so capture the full milestone + member task records to a .bak BEFORE the + # destructive deletes — an accidental archive stays recoverable (phase/gate/waiver/deps + # the record drops). Mirrors the .bak the guideline injector writes before mutating. + _atomic_write( + root / "milestones" / slug / "pre-archive-state.bak.json", + json.dumps({"milestone": ms, "tasks": {s: tasks[s] for s in members}, + "archived_at": _now()}, indent=2) + "\n", + ) + # a slug-list summary (never task bodies) so the active state can't regrow, + # yet cross-milestone deps on these tasks still resolve (see _archived_task_slugs) + state.setdefault("archived", []).append({ + "slug": slug, + "title": ms.get("title", slug), + "tasks": len(members), + "task_slugs": members, + "archived": date.today().isoformat(), + }) + del state["milestones"][slug] + for s in members: + del tasks[s] + if state.get("active_milestone") == slug: + state["active_milestone"] = None + if state.get("active_task") in members: + state["active_task"] = None + save_state(root, state) + print(f"archived milestone '{slug}' ({len(members)} tasks) — removed from active state.") + print("files on disk are untouched; see `add.py status` for the archived rollup.") + + +def cmd_compact(args: argparse.Namespace) -> None: + """Heavy archive (step two, after `archive-milestone`): move a light-archived + milestone's files — MILESTONE.md + siblings + every rollup-member task dir — into + one recovery bundle `.add/archive//`. Validate-all-then-move: any reject + leaves the tree AND state.json byte-for-byte unchanged. Compact never deletes, + only renames; recovery = reverse move, no state edit (state already dropped these + at light archive). Preserves the _archived_task_slugs invariant: `task_slugs` is + never touched — archived ⇒ was PASS-done keeps resolving cross-milestone deps.""" + root = _require_root() + state = load_state(root) + slug = args.slug + # validate before any mutation — a reject must leave tree + state byte-for-byte unchanged + if slug in state.get("milestones", {}): + _die(f"milestone_not_archived: '{slug}' is still active — " + f"run `add.py archive-milestone {slug}` first (light archive is step one)") + entry = next((e for e in state.get("archived", []) if e.get("slug") == slug), None) + if entry is None: + _die("unknown_milestone") + if entry.get("compacted"): + _die(f"already_compacted: '{slug}' was compacted {entry['compacted']} — " + f"see .add/archive/{slug}/") + dest = root / "archive" / slug + if dest.exists(): + _die(f"archive_destination_exists: .add/archive/{slug}/ exists without a " + "compacted stamp — resolve the collision by hand before compacting") + ms_dir = root / "milestones" / slug + members = list(entry.get("task_slugs") or []) + missing = [str(p.relative_to(root)) for p in + [ms_dir, *(root / "tasks" / t for t in members)] if not p.is_dir()] + if missing: + _die("source_files_missing: " + " · ".join(missing)) + # deltas folded first: an `open` lesson inside the bundle would silently vanish + # from `add.py deltas` (_collect_open_deltas globs tasks/*/TASK.md) once moved. + member_set = set(members) + offenders = sorted({e["task"] for v in _collect_open_deltas(root).values() + for e in v if e["task"] in member_set}) + if offenders: + _die("open_deltas_unfolded: consolidate the open lessons first (`add.py deltas`) — " + "open in: " + " · ".join(offenders)) + # every precondition passed — move (same-filesystem renames, never a delete) + def _files(d: Path) -> int: + return sum(1 for f in d.rglob("*") if f.is_file()) + moved: list[tuple[str, int]] = [] + (root / "archive").mkdir(exist_ok=True) + n = _files(ms_dir) + ms_dir.rename(dest) # the milestone dir becomes the bundle root + moved.append((f"milestones/{slug}/", n)) + (dest / "tasks").mkdir(exist_ok=True) + for t in members: + src = root / "tasks" / t + n = _files(src) + src.rename(dest / "tasks" / t) + moved.append((f"tasks/{t}/", n)) + # state write is the LAST step: additive stamp only — task_slugs untouched + entry["compacted"] = date.today().isoformat() + save_state(root, state) + total = sum(n for _, n in moved) + print(f"compacted milestone '{slug}' -> .add/archive/{slug}/ " + f"({len(members)} task dirs, {total} files moved)") + for path, n in moved: + print(f" moved {path} ({n} files)") + print("recovery: reverse the moves (mv the bundle's parts back) — state needs no edit.") + + +def cmd_set_milestone(args: argparse.Namespace) -> None: + root = _require_root() + state = load_state(root) + task = args.task + if task not in state.get("tasks", {}): + _die("unknown_task") + if args.milestone == "none": + new = None + elif args.milestone in state.get("milestones", {}): + new = args.milestone + else: + _die("unknown_milestone") + state["tasks"][task]["milestone"] = new + state["tasks"][task]["updated"] = _now() + save_state(root, state) + print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)") + + +def cmd_use(args: argparse.Namespace) -> None: + """Set the active task to an EXISTING task (switch focus) without scaffolding a new + one or hand-editing state.json. advance/gate/phase still take an explicit slug; `use` + just moves the default focus, closing the only gap that forced manual state edits.""" + root = _require_root() + state = load_state(root) + slug = args.slug + if slug not in state.get("tasks", {}): + _die("unknown_task") + state["active_task"] = slug + save_state(root, state) + print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})") + + +def _find_cycle(tasks: dict) -> list[str] | None: + """Return a cycle path in the depends_on graph, or None. Ignores unknown deps.""" + WHITE, GRAY, BLACK = 0, 1, 2 + color = {s: WHITE for s in tasks} + stack: list[str] = [] + + def visit(node: str) -> list[str] | None: + color[node] = GRAY + stack.append(node) + for dep in tasks[node].get("depends_on") or []: + if dep not in tasks: + continue + if color[dep] == GRAY: + return stack[stack.index(dep):] + [dep] + if color[dep] == WHITE: + found = visit(dep) + if found: + return found + color[node] = BLACK + stack.pop() + return None + + for s in tasks: + if color[s] == WHITE: + found = visit(s) + if found: + return found + return None + + +def _sync_task_marker(root: Path, slug: str, phase: str) -> None: + """Keep the `phase:` line inside TASK.md in sync with state.json.""" + task_md = root / "tasks" / slug / "TASK.md" + if not task_md.exists(): + return + lines = task_md.read_text(encoding="utf-8").splitlines() + changed = False + for i, line in enumerate(lines): + if line.startswith("phase:"): + comment = "" + if "", "", body, flags=re.S) + lines = [ln.rstrip() for ln in body.split("\n")] + while lines and not lines[0].strip(): + lines.pop(0) + while lines and not lines[-1].strip(): + lines.pop() + meaningful = [ln for ln in lines + if ln.strip() and not re.fullmatch(r"\s*<.*>\s*", ln)] + return "\n".join(lines) if meaningful else "(empty)" + + +def _phase_spans(text: str) -> dict[int, str]: + """Split a TASK.md into RAW §1–§7 bodies keyed by section number — the ONE + canonical heading scan (`^##\\s*\\s*·`, case/locale-proof); a body runs from + its heading to the next `## `/`---`/EOF. RAW = byte-faithful lines, no cleaning: + the decision-marker extractor (decide-digest) depends on byte-verbatim text. + KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early — + today's TASK.md bodies don't (box-chars ─═, `### ` sub-heads).""" + lines = text.splitlines() + head = re.compile(r"^##\s*(\d+)\s*·") + starts: dict[int, int] = {} + for idx, ln in enumerate(lines): + m = head.match(ln) + if m: + n = int(m.group(1)) + if 1 <= n <= 7 and n not in starts: + starts[n] = idx + out: dict[int, str] = {} + for n, idx in starts.items(): + body_lines = [] + for ln in lines[idx + 1:]: + if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln): + break + body_lines.append(ln) + out[n] = "\n".join(body_lines) + return out + + +def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]: + """RAW §bodies for one task (byte-faithful, for marker extraction). PURE. + Missing/unreadable TASK.md -> {} (fail-closed, like task_phases).""" + f = root / "tasks" / slug / "TASK.md" + try: + return _phase_spans(f.read_text(encoding="utf-8")) + except OSError: + return {} + + +def task_phases(root: Path, slug: str) -> list[dict]: + """The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §1–§7 into seven + blocks specify→observe. PURE — NO writes. Each entry is + { "phase": , "n": <1..7>, "body": }. + + The heading scan lives in _phase_spans (shared with the decide digest); this view + CLEANS each body. Missing file / missing section / placeholder-only body -> + "(empty)" (fail-closed).""" + names = PHASES[:7] # specify..observe; "done" is a terminal STATE, not a section + f = root / "tasks" / slug / "TASK.md" + try: + text = f.read_text(encoding="utf-8") + except OSError: # missing OR unreadable -> every phase fail-closed to "(empty)" + return [{"phase": names[n - 1], "n": n, "body": "(empty)"} for n in range(1, 8)] + spans = _phase_spans(text) + return [{"phase": names[n - 1], "n": n, + "body": _clean_phase_body(spans[n]) if n in spans else "(empty)"} + for n in range(1, 8)] + + +def _task_title(root: Path, slug: str) -> str: + """The task's display title from TASK.md line 1 `# TASK: ` (fail-soft: the + slug if the file or the header line is missing).""" + f = root / "tasks" / slug / "TASK.md" + try: + text = f.read_text(encoding="utf-8") + except OSError: # missing OR unreadable -> fail-soft to the slug + return slug + for ln in text.splitlines(): + m = re.match(r"^#\s*TASK:\s*(.+)", ln) + if m: + return m.group(1).strip() + return slug + + +def _detail_body(body: str, width: int) -> list[str]: + """Indent a phase body under its block, soft-wrapping over-long physical lines on + spaces while preserving blank lines + each line's leading indent (so scenarios and + contract code keep their shape). Fenced ``` blocks are exempt: delimiter lines and + everything inside an open fence emit BYTE-VERBATIM (indent + raw — no wrap, no + whitespace collapse, even past width) so a copied contract round-trips after + stripping the uniform indent; an unclosed fence runs verbatim to the §body end + (fail-open). Drill-down = reading is the point, never clipped.""" + indent = " " + out: list[str] = [] + fenced = False + for raw in body.split("\n"): + is_delim = raw.lstrip().startswith("```") + if fenced or is_delim: + fenced = fenced != is_delim # delimiter toggles; content keeps state + out.append(indent + raw if raw.strip() else "") + continue + if not raw.strip(): + out.append("") + continue + if len(indent) + len(raw) <= width: + out.append(indent + raw) + continue + lead = raw[: len(raw) - len(raw.lstrip())] + prefix = indent + lead + cur = "" + for w in raw.split(): + cand = f"{cur} {w}".strip() + if cur and len(prefix) + len(cand) > width: + out.append(prefix + cur) + cur = w + else: + cur = cand + if cur: + out.append(prefix + cur) + return out + + +def render_task_detail(root: Path, state: dict, mslug: str, slug: str, *, + width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str: + """Format ONE task's seven phase blocks (specify→observe) as the read-only PHASE + DETAIL: each block shows its number+name, a reached/current/pending marker (from the + task's state phase), and its captured §N body (fail-closed to "(empty)"). The verify + block additionally prints the recorded GATE from state.json — authoritative, NEVER + parsed from prose. Returns PLAIN text (no ANSI); color is a tty-only skin in + cmd_report. PURE — NO writes (the v9 read-only discipline, carried).""" + g = _ASCII if ascii else _UNICODE + W = width + banner, rule = g["h"] * W, " " + g["rule"] * (W - 1) + t = (state.get("tasks") or {}).get(slug, {}) + phase = t.get("phase", "specify") + gate = t.get("gate", "none") + ci = PHASES.index(phase) if phase in PHASES else 0 + + L = [banner, f" {mslug} · {slug} · {_task_title(root, slug)}", banner] + L.append(f" PHASE {phase} GATE {gate}") + L.append(banner) + for p in task_phases(root, slug): + i = p["n"] - 1 + mk = (g["reached"] if (phase == "done" or i < ci) + else g["current"] if i == ci else g["pending"]) + L.append("") + L.append(f" {mk} {p['n']} {p['phase'].upper()}") + L.append(rule) + if p["n"] == 6: # verify: the recorded gate, sourced from state (not prose) + L.append(f" GATE {gate}") + if p["body"] == "(empty)": + L.append(" (empty)") + else: + L.extend(_detail_body(p["body"], W)) + L.append(banner) + return "\n".join(L) + + +def render_report(root: Path, state: dict, mslug: str, *, + width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str: + """Format the FACTS (report_data) as the text DASHBOARD — verdict-first header, + left-aligned ASCII columns (alignment-safe on any locale), Unicode/ASCII glyph + tier, one legend. Returns PLAIN text (no ANSI); color is a tty-only layer in + cmd_report so the persisted RETRO.md string stays plain. NO writes.""" + d = report_data(root, state, mslug) + g = _ASCII if ascii else _UNICODE + W = width + banner, rule = g["h"] * W, g["rule"] * W + m, s = d["milestone"], d["summary"] + done, total = s["tasks_done"], s["tasks_total"] + gates, ec = s["gates"], s["exit_criteria"] + + verdict = ("BLOCKED" if gates["HARD-STOP"] + else "DONE" if total and done == total else "ACTIVE") + gbits = [] + if gates["PASS"]: + gbits.append(f"{gates['PASS']} PASS") + if gates["RISK-ACCEPTED"]: + gbits.append(f"{gates['RISK-ACCEPTED']} RISK") + if gates["HARD-STOP"]: + gbits.append(f"{gates['HARD-STOP']} STOP") + gate_txt = " ".join(gbits) if gbits else "none" + waiver_txt = f"{len(d['waivers'])}" if d["waivers"] else "none" + + # Header: title in the banner, then a 2-col aligned label grid (ASCII-safe cells, + # so no width breakage) — VERDICT leads on its own line for emphasis. + L = [banner, f" {m['slug']} · {m['title']}", banner] + L.append(f" {'VERDICT':<9} {verdict}") + L.append(f" {'TASKS':<9} {f'{done}/{total} done':<18} {'CRITERIA':<9} {ec['met']}/{ec['total']} met") + L.append(f" {'GATES':<9} {gate_txt:<18} {'WAIVERS':<9} {waiver_txt}") + L.append("") + L.extend(_wrap(m["goal"], W - 7, " goal ")) + L.append("") + if d["tasks"]: + L.append(f" {'TASK':<27} {'PHASE':<9} {'GATE':<4} {'TESTS':<5} PROGRESS") + L.append(" " + g["rule"] * (W - 1)) + for r in d["tasks"]: + slug = _clip(r["slug"], 27) + gate = _GATE_SHORT.get(r["gate"], r["gate"]) + tests = f"{r['tests']}†" if r.get("tests_declared") else str(r["tests"]) + L.append(f" {slug:<27} {r['phase']:<9} {gate:<4} " + f"{tests:<5} {_phase_track(r['phase'], g)}") + L.append(f" legend {g['reached']} reached {g['current']} current " + f"{g['pending']} pending spec→…→done") + if any(r.get("tests_declared") for r in d["tasks"]): + L.append(" † counted at the §4-declared path") + else: + L.append(" (no tasks yet)") + L.append("") + L.append(f" EXIT CRITERIA {_bar(ec['met'], ec['total'], 10, g)} {ec['met']}/{ec['total']} met") + if d["waivers"]: # header grid carries the count; show DETAILS here only when present + L.append("") + L.append(f" WAIVERS ({len(d['waivers'])})") + for w in d["waivers"]: + L.extend(_wrap(f"{w['slug']}: {w['owner']} · {w['ticket']} · expires {w['expires']}", + W - 5, f" {g['bullet']} ")) + L.append("") + if d["deltas"]: # the retro's payload — word-wrapped to FULL readable text, never clipped + L.append(f" LEARNINGS ({len(d['deltas'])} carried)") + for x in d["deltas"]: + L.extend(_wrap(x, W - 5, f" {g['bullet']} ")) + else: + L.append(" LEARNINGS none") + L.append("") # DECIDE NEXT footer (v13): always present, APPEND-ONLY + L.extend(_wrap(_decide_next_base(state, d), W - 15, " DECIDE NEXT ")) + if _planned_hint(d): # own segment so the phrase never splits mid-token + L.extend(_wrap(_planned_hint(d).removeprefix(" — "), W - 15, " " * 14)) + L.append(banner) + return "\n".join(L) + + +# ---- decide digest (v13 decide-digest, frozen §3) --------------------------- +# Decision markers: prose conventions surfaced VERBATIM. The engine EXTRACTS; it +# never interprets, scores, or filters — add.py stays judgment-free, the human +# signature is the gate. +_MARKER_PREFIXES = (("⚠", "⚠"), ("- [~]", "[~]"), ("- [ ]", "[ ]")) +_FRONT_PHASES = ("specify", "scenarios", "contract", "tests") + + +def _decision_markers(body: str, section: int) -> list[dict]: + """Extract decision markers from a RAW §body: a line whose first non-space chars + are `⚠` / `- [~]` / `- [ ]`, PLUS its continuation lines (immediately following + non-blank lines indented deeper than the marker). text is BYTE-VERBATIM — never + re-wrapped, never clipped. Fail-open by design (a differently-worded item is + missed); the always-printed count keeps that visible.""" + items: list[dict] = [] + lines = body.split("\n") + i = 0 + while i < len(lines): + ln = lines[i] + stripped = ln.lstrip() + tag = next((t for p, t in _MARKER_PREFIXES if stripped.startswith(p)), None) + if tag is None: + i += 1 + continue + indent = len(ln) - len(stripped) + block = [ln] + j = i + 1 + while j < len(lines): + nxt = lines[j] + ns = nxt.lstrip() + if ns and (len(nxt) - len(ns)) > indent: + block.append(nxt) + j += 1 + else: + break + items.append({"marker": tag, "section": section, "text": "\n".join(block)}) + i = j + return items + + +def _contract_frozen(raw3: str) -> bool: + """§3's `Status:` line is the freeze signal (v12 precedent: the freeze is + artifact-observable; no engine flag). Missing Status -> DRAFT (fail-closed).""" + return any(re.match(r"\s*Status:\s*FROZEN", ln) for ln in raw3.splitlines()) + + +_FLAG_LABEL_RE = re.compile(r"Least-sure flag surfaced at freeze\s*:", re.I) +_FLAG_PART_RE = re.compile( + r"\[(?:spec|scenario|contract|test)(?:/(?:spec|scenario|contract|test))*\]") +_FLAG_NONE_ESCAPE_RE = re.compile( + r"none material\s*[—-]+\s*biggest risk\s*:\s*\S", re.I) + + +def _flag_well_formed(raw3: str) -> bool: + """A FROZEN §3 must surface a WELL-FORMED lowest-confidence flag — the unit + that NAMES which part of the bundle is least certain. Well-formed := the label + phrase + a unit carrying >=1 [part] tag (part in spec/scenario/contract/test, + slash-joinable like [spec/contract]) + substantive content. A bare 'none' is + refused unless it takes the honest escape 'none material — biggest risk: X'. + why/cost stay a human-read convention, never machine keywords (evidence: the + lived flags use em-dash/prose, never literal because/if-wrong). HTML comments + (template hints) never count. PURE — fail-closed on a missing label.""" + body = re.sub(r"<!--.*?-->", "", raw3, flags=re.S) + m = _FLAG_LABEL_RE.search(body) + if not m: + return False + unit = body[m.end():].strip() + if not unit: + return False + if _FLAG_NONE_ESCAPE_RE.search(unit): # the honest-none escape — no tag needed + return True + if not _FLAG_PART_RE.search(unit): # must name WHICH part is uncertain + return False + residue = _FLAG_PART_RE.sub("", unit).replace("⚠", "").strip(" -—·\n\t") + return len(residue) >= 3 # substantive content beyond the tag(s) + + +def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict: + """FACTS for the task-level decision-point digest (frozen shape). The decision comes + from STATE ONLY: recorded (gate set / observe / done) · front (specify→tests) · + gate (build/verify). judgment = extracted markers, byte-verbatim. PURE.""" + tasks = state.get("tasks") or {} + t = tasks.get(slug, {}) + phase = t.get("phase", "specify") + gate = t.get("gate", "none") + if gate != "none" or phase in ("observe", "done"): + seam = "recorded" + elif phase in _FRONT_PHASES: + seam = "front" + else: + seam = "gate" + raw = _raw_phase_bodies(root, slug) + frozen = _contract_frozen(raw.get(3, "")) + if seam == "gate": # the items closest to the gate lead: §6 first, then §1 + judgment = _decision_markers(raw.get(6, ""), 6) + _decision_markers(raw.get(1, ""), 1) + elif seam == "front" and not frozen: + judgment = _decision_markers(raw.get(1, ""), 1) + _decision_markers(raw.get(3, ""), 3) + else: + judgment = [] + + members = [x for x in tasks.values() if x.get("milestone") == mslug] + done, total = sum(1 for x in members if _task_done(x)), len(members) + facts = {"phase": phase, "gate": gate, + "deps": [{"slug": d, "gate": tasks.get(d, {}).get("gate", "none")} + for d in t.get("depends_on", [])], + "tests": _tests_info(root, slug)[0]} + + if seam == "gate": + unlocks = f"gate PASS -> task done -> milestone {min(done + 1, total)}/{total}" + decide = "add.py gate PASS | RISK-ACCEPTED | HARD-STOP" + elif seam == "front" and not frozen: + unlocks = "freeze §3 -> the auto run takes build -> verify (autonomy: auto by default)" + decide = "approve -> freeze §3 (Status: FROZEN @ v1) -> auto run" + elif seam == "front": + unlocks = "none" + decide = "no decision pending — frozen; the run owns it. next decision point: verify gate" + else: + unlocks = "none" + decide = f"no decision pending — recorded gate: {gate}" + return {"seam": seam, "milestone": mslug, "task": slug, "phase": phase, + "gate": gate, "judgment": judgment, "facts": facts, + "unlocks": unlocks, "decide": decide} + + +def render_decide(root: Path, state: dict, mslug: str, slug: str, *, + width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str: + """Text view of the decision-point digest — decisive facts FIRST: NEEDS YOUR + JUDGMENT (markers byte-verbatim, section-tagged) -> [front: §3 verbatim] -> + ENGINE FACTS -> UNLOCKS -> DECIDE. PURE — no writes; plain text (color is a + tty-only skin in cmd_report, like every report view).""" + d = decide_data(root, state, mslug, slug) + g = _ASCII if ascii else _UNICODE + banner = g["h"] * width + seam_label = {"gate": "VERIFY GATE", "front": "CONTRACT APPROVAL", + "recorded": "RECORDED"}[d["seam"]] + L = [banner, f" DECIDE · {mslug or '—'} · {slug} · decision point: {seam_label}", banner] + if d["decide"].startswith("no decision pending"): + L.append(f" {d['decide']}") + L.append(f" GATE {d['gate']}") + L.append(banner) + return "\n".join(L) + L.append(f" NEEDS YOUR JUDGMENT ({len(d['judgment'])})") + for item in d["judgment"]: + L.append(f" [§{item['section']}]") + L.extend(item["text"].split("\n")) # byte-verbatim — never wrapped/clipped + if d["seam"] == "front": + L.append("") + L.append(" CONTRACT (§3 verbatim)") + L.extend(_raw_phase_bodies(root, slug).get(3, "").split("\n")) + L.append(" STATUS DRAFT") + f = d["facts"] + deps_txt = " ".join(f"{x['slug']}:{x['gate']}" for x in f["deps"]) or "none" + L.append("") + L.append(f" ENGINE FACTS phase {f['phase']} · gate {f['gate']} · " + f"deps {deps_txt} · tests {f['tests']}") + L.append(f" UNLOCKS {d['unlocks']}") + L.append(f" DECIDE {d['decide']}") + L.append(banner) + return "\n".join(L) + + +def _planned_unscaffolded(root: Path, mslug: str) -> list[str]: + """Slugs MILESTONE.md plans (rows `- [ ] <slug> …`) that have no TASK.md yet — + the plan-vs-state diff. Only valid-slug first-tokens match (a template + placeholder like <slug> never does); file order, deduped; fail-closed [].""" + md = root / "milestones" / mslug / "MILESTONE.md" + try: + text = md.read_text(encoding="utf-8") + except OSError: + return [] + out: list[str] = [] + for sec in re.split(r"^## ", text, flags=re.M)[1:]: + if not sec.startswith("Tasks"): # only the Tasks list — never exit criteria + continue + for m in re.finditer(r"^- \[[ x~]\] ([A-Za-z0-9_-]+)\b", sec, re.M): + slug = m.group(1) + if slug not in out and not (root / "tasks" / slug / "TASK.md").is_file(): + out.append(slug) + return out + + +def _decide_next(state: dict, d: dict) -> str: + """The rollup's DECIDE NEXT line (frozen precedence): HARD-STOP -> consolidate+archive + -> first decision-blocked task (ACTIVE task first, then state order) -> run-in- + progress. v2: when d carries planned_unscaffolded, the line gains a + plan-vs-state suffix — precedence itself stays state-only.""" + return _decide_next_base(state, d) + _planned_hint(d) + + +def _planned_hint(d: dict) -> str: + """The plan-vs-state suffix ('' when nothing is missing). Text renders emit it + as its OWN wrapped segment so the phrase never splits mid-token; the JSON + 'decide' string carries it inline via _decide_next.""" + planned = d.get("planned_unscaffolded") or [] + if not planned: + return "" + return f" — {len(planned)} planned not yet scaffolded: " + " · ".join(planned) + + +def _decide_next_base(state: dict, d: dict) -> str: + ms = d["milestone"]["slug"] + rows = d["tasks"] + if not rows: + return "none — no tasks yet" + stopped = [r for r in rows if r["gate"] == "HARD-STOP"] + if stopped: + return f"resolve HARD-STOP on {stopped[0]['slug']}" + s = d["summary"] + if s["tasks_done"] == s["tasks_total"]: + # tasks complete — but the milestone holds while the goal (exit criteria) is + # unmet (v20). Point at the feed-forward inventory the loop draws from, instead + # of "archive". Fires only when criteria exist; else the prompt is unchanged. + ec = s.get("exit_criteria") or {} + met, total = ec.get("met", 0), ec.get("total", 0) + if total > 0 and met < total: + return (f"goal not met ({met}/{total} exit criteria) — propose next tasks " + f"from open deltas / the unscaffolded plan (add.py deltas)") + return f"consolidate learnings + archive-milestone {ms}" + active = state.get("active_task") + order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable + for r in order: + if r["done"]: + continue + if r["phase"] in _FRONT_PHASES: + return (f"approve the contract of {r['slug']} — " + f"add.py report {ms} {r['slug']} --decide") + if r["phase"] == "verify" and r["gate"] == "none": + return f"gate {r['slug']} — add.py report {ms} {r['slug']} --decide" + r = next(x for x in order if not x["done"]) + return f"none — run in progress ({r['slug']} at {r['phase']})" + + +def render_decide_next(root: Path, state: dict, mslug: str, *, + width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str: + """`report <ms> --decide`: ONLY the DECIDE NEXT block (no rollup table). PURE.""" + g = _ASCII if ascii else _UNICODE + banner = g["h"] * width + d = report_data(root, state, mslug) + L = [banner, f" {mslug} · DECIDE NEXT", banner] + L.extend(_wrap(_decide_next_base(state, d), width - 4, " ")) + if _planned_hint(d): # own segment so the phrase never splits mid-token + L.extend(_wrap(_planned_hint(d).removeprefix(" — "), width - 4, " ")) + L.append(banner) + return "\n".join(L) + + +def _write_retro(root: Path, state: dict, mslug: str) -> Path: + """Persist the milestone's CANONICAL render to .add/milestones/<mslug>/RETRO.md + (the spec'd 'Milestone exit report', appendix-f). Reuses the ONE frozen renderer + at its canonical args (width 72, ascii=False) so the doc is byte-identical to a + piped `report <mslug>`. PURE on state: reads via render_report, writes exactly + this one file with explicit utf-8 (the canonical carries Unicode glyphs — never + trust the locale default), never mutates state.json.""" + content = render_report(root, state, mslug, width=_DEFAULT_WIDTH, ascii=False) + path = root / "milestones" / mslug / "RETRO.md" + _atomic_write(path, content) # honor the module's atomic-write contract (no half-write) + return path + + +_COMPETENCY_ORDER = ("DDD", "SDD", "UDD", "TDD", "ADD") +_DELTA_STATUSES = ("open", "folded", "rejected") + +# Canonical delta grammar — the single compiled source for the enumerated +# competency · status shape. Leading \s* is PERMISSIVE so _task_prose can feed +# un-stripped lines directly; callers that pre-strip their input +# (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s* +# matches zero). Anchored at line-start via re.match. +_DELTA_RE = re.compile( + r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$" +) +_EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$") + +# Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed). +# A line with a `· ` bracket separator is a delta-attempt. Does NOT enumerate +# competencies or statuses — a different abstraction from _DELTA_RE (no DRY violation). +_TAG_BROAD_RE = re.compile(r"^\s*-\s*\[\s*([^\]·]+?)\s*·\s*([^\]·]+?)\s*\]\s*(.*)$") + + +def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None: + """Lint all open delta entries in a task's '### Competency deltas' block. + + Returns: + None — no delta-attempts found; no check emitted. + (True, "") — all open entries pass. + (False, "<code> -> <tag line>") — first failing entry with its failure code. + + Contract rules (frozen §3, v1): + - SKIP HTML-comment lines and blank lines (they are never tag lines). + - Group lines into ENTRIES: a broad tag line starts an entry; following lines + until next tag / blank / end-of-block are its continuation. + - A line without a '· ' separator inside brackets (e.g. '- [x]') is NOT a tag. + - For each entry, skip folded/rejected (open-only — history not retrofitted). + - Validate the remaining (open) entries: COMP in _COMPETENCY_ORDER, + status in _DELTA_STATUSES, and '(evidence:' present SOMEWHERE in the unit. + - Fail-closed: an unparseable attempt FAILS (never silently passes). + """ + task_md = root / "tasks" / slug / "TASK.md" + if not task_md.exists(): + return None + try: + text = task_md.read_text(encoding="utf-8") + except OSError: + return None + + # Locate the "### Competency deltas" block. + block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S) + if not block_match: + return None + + block = block_match.group(1) + raw_lines = block.splitlines() + + # First pass: collect entries (tag line + continuations). + # HTML-comment lines are skipped entirely (invisible to the guard). + # Blank lines terminate the current entry, but are not tags themselves. + entries: list[tuple[str, list[str]]] = [] # (tag_line, [tag_line, *continuations]) + current: list[str] | None = None + for raw_line in raw_lines: + stripped = raw_line.strip() + # Skip HTML-comment lines. + if stripped.startswith("<!--"): + continue + # Blank line terminates the current entry. + if not stripped: + current = None + continue + # Broad tag detection: any "- [tok · tok]" line starts a new entry. + m = _TAG_BROAD_RE.match(raw_line) + if m: + current = [stripped] + entries.append((stripped, current)) + elif current is not None: + # Continuation line of the current entry. + current.append(stripped) + # else: non-blank, non-comment, non-tag line with no prior entry — ignore. + + if not entries: + return None # no delta-attempts → no check emitted + + # Second pass: validate each entry. + for tag_line, unit_lines in entries: + m = _TAG_BROAD_RE.match(tag_line) + if not m: + # Should not happen, but fail-closed. + return False, f"malformed_delta -> {tag_line}" + raw_comp = m.group(1).strip() + raw_status = m.group(2).strip() + + # Step 1: skip historical entries (folded/rejected) — open-only enforcement. + # MUST happen before competency/status validation per §3: "history not retrofitted". + if raw_status in ("folded", "rejected"): + continue + + # Step 2: use _DELTA_RE (the canonical grammar, single source of truth) to test + # whether the tag line is a fully-valid delta shape. If it matches, check evidence + # only. If it fails, classify the failure via the raw tokens (never a parallel grammar). + unit_text = " ".join(unit_lines) + if _DELTA_RE.match(tag_line): + # Valid comp + status + non-empty tail — check evidence in the joined unit. + if "(evidence:" not in unit_text: + return False, f"no_evidence -> {tag_line}" + else: + # Classify why _DELTA_RE rejected it (open entries only — folded/rejected skipped). + if raw_comp not in _COMPETENCY_ORDER: + return False, f"unknown_competency -> {tag_line}" + if raw_status not in _DELTA_STATUSES: + return False, f"unknown_status -> {tag_line}" + # Comp and status are valid but the line still failed _DELTA_RE (e.g. empty tail). + return False, f"malformed_delta -> {tag_line}" + + return True, "" + + +def _collect_open_deltas(root: Path) -> dict[str, list[dict]]: + """Scan every .add/tasks/*/TASK.md for open lessons learned. + + Returns a dict keyed by competency in canonical order; each value is a list + of {task, text, evidence} dicts. READ-ONLY — never mutates any file.""" + by_comp: dict[str, list[dict]] = {c: [] for c in _COMPETENCY_ORDER} + tasks_dir = root / "tasks" + if not tasks_dir.is_dir(): + return by_comp + for task_md in sorted(tasks_dir.glob("*/TASK.md")): + slug = task_md.parent.name + try: + text = task_md.read_text(encoding="utf-8") + except OSError: + continue + # Locate the "### Competency deltas" block (may appear anywhere in the file). + block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S) + if not block_match: + continue + block = block_match.group(1) + # Group lines into entries (tag line + continuations) so a multi-line delta — + # whose learning wraps and whose (evidence: …) may land on a later line — is read + # in FULL, not truncated to its first line. A tag line starts an entry; a line + # that does not begin a new "- " list item continues it; a blank/comment or a + # new "- " item ends it (a trailing malformed item can't pollute a delta's text). + entries: list[list[str]] = [] + current: list[str] | None = None + for line in block.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("<!--"): + current = None + continue + if _DELTA_RE.match(stripped): + current = [stripped] + entries.append(current) + elif current is not None and not stripped.startswith("-"): + current.append(stripped) # genuine wrap of the current learning + else: + current = None # a new / malformed list item ends the run + for unit in entries: + m = _DELTA_RE.match(unit[0]) + comp, status = m.group(1), m.group(2) + if status != "open": + continue + # Join the tag line's tail with any continuation lines, then split evidence. + tail = " ".join([m.group(3).strip(), *unit[1:]]).strip() + em = _EVIDENCE_RE.match(tail) + if em: + delta_text, evidence = em.group(1).strip(), em.group(2).strip() + else: + delta_text, evidence = tail, "" + by_comp[comp].append({"task": slug, "text": delta_text, "evidence": evidence}) + return by_comp + + +_AUDIT_STAMP_RE = re.compile(r"Status:\s*FROZEN @ v\d+\s*[—-]+\s*approved by\s+\S+") +_AUDIT_OUTCOME_RE = re.compile(r"^Outcome:\s*(PASS|RISK-ACCEPTED|HARD-STOP)\b", re.M) +_AUDIT_SECURITY_RE = re.compile( + r"^\s*- \[[ x~]\] no exposed secrets.*(?:\n(?!\s*- \[|#).*)*", re.M) +_AUDIT_REVIEWED_RE = re.compile(r"^Reviewed by:(.*)$", re.M) + + +def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]: + """The gate-audit core: verify that human decision points left WELL-FORMED records. + Judgment-free — checks record SHAPE (a named human at the freeze, exactly one + gate outcome, prose ≡ state, a marked security note never auto-reviewed), + never re-decides an outcome. Scope: active tasks done/observe or gated; open + fronts skipped. PURE — reads only. Honest limit: shape, not engagement — a + forged name passes; CI wiring makes forgery explicit and attributable.""" + tasks = state.get("tasks") or {} + checked, findings = 0, [] + + def f(slug: str, code: str, detail: str) -> None: + findings.append({"task": slug, "code": code, "detail": detail}) + + for slug in sorted(tasks): + t = tasks[slug] + phase, gate = t.get("phase", "specify"), t.get("gate", "none") + if phase not in ("done", "observe") and gate == "none": + continue # the front is still open — nothing recorded to audit + checked += 1 + raw = _raw_phase_bodies(root, slug) + s3, s6 = raw.get(3, ""), raw.get(6, "") + if not _AUDIT_STAMP_RE.search(s3): + f(slug, "unstamped_freeze", + "§3 lacks 'Status: FROZEN @ vN — approved by <name>'") + # verified-marker discriminator (task unflagged-freeze): enforce the + # lowest-confidence flag ONLY on records that crossed the guard (flag_verified). + # A marked record whose flag was deleted/corrupted post-freeze is + # tampering; unmarked predecessors are skipped — the board is never + # retro-redded. + if t.get("flag_verified") and not _flag_well_formed(s3): + f(slug, "unflagged_freeze", + "flag_verified record lost its well-formed " + "'Least-sure flag surfaced at freeze:' unit") + outcomes = _AUDIT_OUTCOME_RE.findall(s6) + if len(outcomes) != 1: + f(slug, "malformed_gate_record", + f"{len(outcomes)} Outcome lines in §6 (need exactly 1)") + elif gate != "none" and outcomes[0] != gate: + f(slug, "gate_record_mismatch", + f"§6 records {outcomes[0]} but state.json records {gate}") + sec = _AUDIT_SECURITY_RE.search(s6) + marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0))) + rev = _AUDIT_REVIEWED_RE.search(s6) + if marked and rev and "auto-gate" in rev.group(1): + f(slug, "unescalated_security_note", + "security-line note (NOTE/⚠) with an auto-gate reviewer") + # F7 unguarded_high_risk_auto (task high-risk-signal, v14): a declared + # high-risk record must show a guarded dial AND a human at the gate — + # catches post-gate header tampering and auto-resolved high-risk gates. + hdr = _task_header(root, slug) + if _RISK_HIGH_RE.search(hdr): + if not _AUTONOMY_CONSERVATIVE_RE.search(hdr): + f(slug, "unguarded_high_risk_auto", + "risk: high declared but autonomy is not 'conservative'") + elif rev and "auto-gate" in rev.group(1): + f(slug, "unguarded_high_risk_auto", + "risk: high task whose GATE RECORD reviewer is the auto-gate") + if outcomes == ["RISK-ACCEPTED"]: + if marked: + f(slug, "risk_accepted_security", + "a waiver on a marked security item is never allowed") + if not all(re.search(rf"{k}:\s*(?!<)\S", s6) + for k in ("owner", "ticket", "expires")): + f(slug, "waiver_incomplete", + "RISK-ACCEPTED needs owner · ticket · expires") + return checked, findings + + +def cmd_audit(args: argparse.Namespace) -> None: + """Read-only: audit recorded human decision points for well-formedness. Exit 0 clean, + exit 1 with findings — the enforcement gate CI consumes (audit-ci). Writes + NOTHING; every other command is byte-identical.""" + root = _require_root() + checked, findings = _audit_findings(root, load_state(root)) + if getattr(args, "json", False): + print(json.dumps({"checked": checked, "findings": findings}, + ensure_ascii=False, indent=2)) + else: + if findings: + for x in findings: + print(f"audit: {x['code']} {x['task']} — {x['detail']}") + else: + print(f"audit: clean ({checked} tasks checked)") + if findings: + sys.exit(1) + + +def _retro_carried(path: Path) -> int: + """Parse the 'LEARNINGS (N carried)' count from a RETRO.md; absent/unreadable -> 0. + READ-ONLY (the graduation harvest's carried-delta facet for the consolidated tier).""" + try: + text = path.read_text(encoding="utf-8") + except OSError: + return 0 + m = re.search(r"LEARNINGS \((\d+) carried\)", text) + return int(m.group(1)) if m else 0 + + +def graduation_data(root: Path, state: dict) -> dict: + """The single source of FACTS for the graduation harvest — PURE, NO writes (mirrors + report_data). Both the `graduation-report` text dashboard and `--json` render from this + one dict, so the human view and the machine view can never disagree. + + GATHER, never JUDGE: every value is a RECORD the human verifies by looking; there is no + readiness/score/ranking field by construction (would_be_judging is structurally impossible). + Two tiers: LIVE = in-state (state + on-disk TASK.md); CONSOLIDATED = compacted milestones, + a RETRO record only. A missing/unreadable source is SKIPPED, never a crash (fail-closed).""" + tasks = state.get("tasks") or {} + milestones = state.get("milestones") or {} + archived = state.get("archived") or [] + + # a — open deltas by competency (reuse the project-wide harvester; compacted folded out) + by_comp = _collect_open_deltas(root) + open_deltas = {"total": sum(len(v) for v in by_comp.values()), + "by_competency": {c: v for c, v in by_comp.items() if v}} + + # b — open RISK-ACCEPTED waivers, soonest expiry first (missing/unparseable expiry sorts LAST) + waivers = [] + for slug, t in tasks.items(): + if t.get("gate") == "RISK-ACCEPTED" and t.get("waiver"): + w = t["waiver"] + waivers.append({"slug": slug, "owner": w.get("owner", "?"), + "ticket": w.get("ticket", "?"), "expires": w.get("expires", "?")}) + + def _exp_key(wv): + try: + return (0, date.fromisoformat(wv["expires"]).isoformat()) + except (ValueError, TypeError): + return (1, "") # unparseable/missing -> after every real date + waivers.sort(key=_exp_key) + + # c — RETRO records: LIVE under milestones/, CONSOLIDATED under archive/ (the compacted backbone) + retros = [] + for sub_dir, tier in ((root / "milestones", "live"), (root / "archive", "consolidated")): + if sub_dir.is_dir(): + for retro in sorted(sub_dir.glob("*/RETRO.md")): + if retro.is_file(): # a directory at the path is not a ledger (fail-closed) + retros.append({"milestone": retro.parent.name, + "path": str(retro.relative_to(root)), + "carried_deltas": _retro_carried(retro), "tier": tier}) + + # d-i — residue gate records: the residue-class facet (RISK-ACCEPTED shares the waivers[] record) + residue_gates = [{"slug": s, "gate": t.get("gate")} for s, t in tasks.items() + if t.get("gate") in ("RISK-ACCEPTED", "HARD-STOP")] + + # d-ii — §6 disclosed residue: in-state tasks' '- [⚠]' VERIFY list items (the pinned rule) + # e — coverage-gaps proxy: in-state §7 Watch still the '<error rate' placeholder head + residue_disclosed, coverage_gaps = [], [] + for slug in tasks: + try: + text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8") + except OSError: + continue # unreadable TASK.md -> skip this task's prose records + m = re.search(r"##\s*6\b.*?(?=\n##\s*\d|\Z)", text, re.S) # the VERIFY section only + for line in (m.group(0) if m else "").splitlines(): + st = line.strip() + if st.startswith("- [⚠]"): + residue_disclosed.append({"slug": slug, "line": st[len("- [⚠]"):].strip()}) + for line in text.splitlines(): + if line.startswith("Watch") and "<error rate" in line: # unfilled <…> template head + coverage_gaps.append({"slug": slug}) + break + + return { + "open_deltas": open_deltas, + "waivers": waivers, + "retros": retros, + "residue_gates": residue_gates, + "residue_disclosed": residue_disclosed, + "coverage_gaps": coverage_gaps, + "summary": { + "open_deltas": open_deltas["total"], "waivers": len(waivers), "retros": len(retros), + "residue_gates": len(residue_gates), "residue_disclosed": len(residue_disclosed), + "coverage_gaps": len(coverage_gaps), + "milestones_live": len(milestones), "milestones_consolidated": len(archived), + }, + } + + +def cmd_graduation_report(args: argparse.Namespace) -> None: + """Read-only: GATHER the MVP loop's evidence into five labeled record-sets for the + graduate.md interview. text (default) or --json (the frozen JSON facts interface). Exit 0 ALWAYS — + a gather, not a gate; the ONLY non-zero exit is no_project. Judges nothing. NO writes.""" + root = find_root() + if root is None: # frozen contract: fail-closed with a no_project signal + _die("no_project: no .add/ project found. Run `add.py init` first.") + state = load_state(root) + d = graduation_data(root, state) + + if getattr(args, "json", False): + print(json.dumps(d, ensure_ascii=False, indent=2)) + return + + s = d["summary"] + L = ["GRADUATION REPORT — MVP-loop evidence (gather, not judge)", ""] + L.append(f"Open deltas ({s['open_deltas']}) — unfolded lessons by competency:") + for comp, entries in d["open_deltas"]["by_competency"].items(): + for e in entries: + L.append(f" - [{comp}] {e['text']} [{e['task']}]") + L.append("") + L.append(f"Waivers ({s['waivers']}) — open RISK-ACCEPTED, soonest expiry first:") + for w in d["waivers"]: + L.append(f" - {w['slug']}: {w['owner']} · {w['ticket']} · expires {w['expires']}") + L.append("") + _live_retros = sum(1 for r in d["retros"] if r["tier"] == "live") + _cons_retros = s["retros"] - _live_retros + L.append(f"RETRO records ({s['retros']}: {_live_retros} live · {_cons_retros} consolidated) — " + f"milestones: {s['milestones_live']} live · " + f"{s['milestones_consolidated']} represented by RETRO record:") + for r in d["retros"]: + L.append(f" - {r['milestone']} [{r['tier']}]: {r['path']} ({r['carried_deltas']} carried)") + L.append("") + L.append(f"Verify residue — gate records ({s['residue_gates']}, RISK-ACCEPTED/HARD-STOP):") + for g in d["residue_gates"]: + L.append(f" - {g['slug']}: {g['gate']}") + L.append(f"Verify residue — disclosed §6 lines ({s['residue_disclosed']}):") + for r in d["residue_disclosed"]: + L.append(f" - {r['slug']}: {r['line']}") + L.append("") + L.append(f"Coverage gaps ({s['coverage_gaps']}) — PROXY (monitor not declared; §7 Watch unfilled):") + for c in d["coverage_gaps"]: + L.append(f" - {c['slug']}") + print("\n".join(L)) + + +def cmd_deltas(args: argparse.Namespace) -> None: + """Read-only: report all open lessons learned grouped by competency. + + Scans every .add/tasks/*/TASK.md '### Competency deltas' block for lines + matching the delta grammar; shows only `open` entries in canonical competency + order (DDD·SDD·UDD·TDD·ADD). --json emits one JSON object. Exit 0 ALWAYS. + Writes NOTHING.""" + root = _require_root() + by_comp = _collect_open_deltas(root) + total = sum(len(v) for v in by_comp.values()) + + if getattr(args, "json", False): + payload: dict = { + "total": total, + "by_competency": {c: v for c, v in by_comp.items() if v}, + } + print(json.dumps(payload, ensure_ascii=False)) + return + + if total == 0: + print("no open deltas.") + return + + print(f"open lessons learned ({total} total):") + for comp in _COMPETENCY_ORDER: + entries = by_comp[comp] + if not entries: + continue + print(f" {comp} ({len(entries)}):") + for e in entries: + print(f" - {e['text']} [{e['task']}]") + + +def cmd_project(args: argparse.Namespace) -> None: + """Read-only: print .add/PROJECT.md (the read-first foundation) in one command. + + Fail-closed: a missing foundation dies with a clear stderr message + a non-zero + exit, never a silent empty print. Writes NOTHING.""" + root = _require_root() + foundation = root / "PROJECT.md" + if not foundation.exists(): + _die("missing foundation: .add/PROJECT.md (run `add.py init` to scaffold it)") + print(foundation.read_text(encoding="utf-8"), end="") + + +def cmd_report(args: argparse.Namespace) -> None: + """Read-only: capture a milestone's raw data (--json) or render the text + dashboard (color on a tty, ASCII when the terminal can't do Unicode, --plain + forces the pipe/screen-reader-safe tier). Writes nothing, never mutates state.""" + root = _require_root() + state = load_state(root) + milestones = state.get("milestones") or {} + tasks = state.get("tasks") or {} + name = args.milestone # 1st positional (SMART: milestone-first, else task) + task = getattr(args, "task", None) + + # Resolve to a ROLLUP (mslug) or a DRILL (mslug + drill_task). Drill path is purely + # additive; the rollup branches are byte-for-byte the v9 behavior. + drill_task = None + if task is not None: # explicit `report <m> <task>` + mslug = name + if mslug not in milestones: + _die(f"unknown_milestone: '{mslug}' is not a milestone") + if tasks.get(task, {}).get("milestone") != mslug: + _die(f"unknown_task: '{task}' is not a task of milestone '{mslug}'") + drill_task = task + elif name is not None: # smart single positional + if name in milestones: + mslug = name # -> rollup (unchanged) + elif name in tasks: # -> drill by task name + drill_task = name + mslug = tasks[name].get("milestone") + if not mslug: + _die(f"unknown_milestone: task '{name}' is not attached to a milestone") + else: + _die(f"unknown_milestone: '{name}' is not a milestone") + elif getattr(args, "decide", False): # bare --decide -> the ACTIVE TASK + slug = state.get("active_task") + if not slug or slug not in tasks: + _die("no_active_task — name one: add.py report <milestone> <task> --decide") + drill_task = slug + mslug = tasks[slug].get("milestone") or "" + else: # no positional -> active milestone + mslug = state.get("active_milestone") + if not mslug: + _die("no_active_milestone: no milestone given and none is active; " + "try `add.py report <milestone>`") + if mslug not in milestones: + _die(f"unknown_milestone: '{mslug}' is not a milestone") + + if getattr(args, "decide", False): + # Decision-seam digest (v13): task -> seam digest; milestone -> DECIDE NEXT + # block only. PURE, like every report path. + if getattr(args, "json", False): + if drill_task: + payload = decide_data(root, state, mslug, drill_task) + else: # milestone altitude: same frozen key set, task null + d = report_data(root, state, mslug) + payload = {"seam": "milestone", "milestone": mslug, "task": None, + "phase": "", "gate": "none", "judgment": [], + "facts": {"phase": "", "gate": "none", "deps": [], "tests": 0}, + "unlocks": "", "decide": _decide_next(state, d)} + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return + plain = getattr(args, "plain", False) + interactive = sys.stdout.isatty() and not plain + width = _term_width() if interactive else _DEFAULT_WIDTH + use_ascii = plain or _use_ascii() + out = (render_decide(root, state, mslug, drill_task, width=width, ascii=use_ascii) + if drill_task else + render_decide_next(root, state, mslug, width=width, ascii=use_ascii)) + if not plain and _color_enabled(): + out = _colorize(out) + print(out) + return + + if getattr(args, "json", False): + # POLYMORPHIC by path: drill -> task_phases list; rollup -> report_data dict. + payload = task_phases(root, drill_task) if drill_task \ + else report_data(root, state, mslug) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return + plain = getattr(args, "plain", False) + interactive = sys.stdout.isatty() and not plain + width = _term_width() if interactive else _DEFAULT_WIDTH + use_ascii = plain or _use_ascii() + out = (render_task_detail(root, state, mslug, drill_task, width=width, ascii=use_ascii) + if drill_task else + render_report(root, state, mslug, width=width, ascii=use_ascii)) + if not plain and _color_enabled(): + out = _colorize(out) + print(out) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="add.py", description="ADD scaffolder + state tracker") + sub = p.add_subparsers(dest="cmd", required=True) + + pi = sub.add_parser("init", help="create a .add/ project here") + pi.add_argument("--dir", default=".", help="target directory (default: cwd)") + pi.add_argument("--name", default=None, help="project name (default: dir name)") + pi.add_argument("--stage", default="prototype", choices=STAGES) + pi.add_argument("--force", action="store_true", help="reset state.json if present") + pi.add_argument("--await-lock", dest="await_lock", action="store_true", + help="seed an unlocked setup; gates new-task/advance/gate until `add.py lock`") + pi.set_defaults(func=cmd_init) + + pl = sub.add_parser("lock", + help="freeze the autonomous setup (the human baseline approval) and open the build") + pl.add_argument("--by", default=None, help="who is locking (default: current OS user)") + pl.add_argument("--layers", default=None, + help="comma-separated lock layers (default: foundation,scope,contract)") + pl.add_argument("--force", action="store_true", help="re-lock an already-locked project") + pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text") + pl.set_defaults(func=cmd_lock) + + pn = sub.add_parser("new-task", help="scaffold a new task (TASK.md + tests/ + src/)") + pn.add_argument("slug") + pn.add_argument("--title", default=None) + pn.add_argument("--milestone", default=None, help="attach to a milestone (default: active)") + pn.add_argument("--depends-on", dest="depends_on", default=None, + help="comma-separated task slugs this task depends on") + pn.add_argument("--force", action="store_true", help="overwrite TASK.md if present") + pn.set_defaults(func=cmd_new_task) + + pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)") + pm.add_argument("slug") + pm.add_argument("--title", default=None) + pm.add_argument("--goal", default=None, help="one-sentence outcome") + pm.add_argument("--stage", default="mvp", choices=STAGES) + pm.add_argument("--force", action="store_true", help="overwrite MILESTONE.md if present") + pm.set_defaults(func=cmd_new_milestone) + + pr = sub.add_parser("ready", help="list tasks whose dependencies are satisfied") + pr.add_argument("--json", action="store_true", help="machine-readable JSON output") + pr.set_defaults(func=cmd_ready) + + pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)") + pmd.add_argument("slug") + pmd.set_defaults(func=cmd_milestone_done) + + psm = sub.add_parser("set-milestone", help="attach/move/detach an existing task") + psm.add_argument("task") + psm.add_argument("milestone", help="milestone slug, or 'none' to detach") + psm.set_defaults(func=cmd_set_milestone) + + pu = sub.add_parser("use", help="set the active task to an existing one (switch focus)") + pu.add_argument("slug") + pu.set_defaults(func=cmd_use) + + pam = sub.add_parser("archive-milestone", + help="collapse a done milestone out of active state (files stay on disk)") + pam.add_argument("slug") + pam.set_defaults(func=cmd_archive_milestone) + + pco = sub.add_parser("compact", + help="heavy archive: move an archived milestone's files into " + ".add/archive/<slug>/ (recoverable reverse move)") + pco.add_argument("slug") + pco.set_defaults(func=cmd_compact) + + pp = sub.add_parser("phase", help="set a task's phase explicitly") + pp.add_argument("phase", choices=PHASES) + pp.add_argument("slug", nargs="?", default=None) + pp.set_defaults(func=cmd_phase) + + pa = sub.add_parser("advance", help="move a task to the next phase") + pa.add_argument("slug", nargs="?", default=None) + pa.set_defaults(func=cmd_advance) + + pg = sub.add_parser("gate", help="record a verify gate outcome") + pg.add_argument("outcome", choices=GATES) + pg.add_argument("slug", nargs="?", default=None) + pg.add_argument("--owner", help="RISK-ACCEPTED waiver: accountable owner") + pg.add_argument("--ticket", help="RISK-ACCEPTED waiver: tracking ticket/link") + pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date") + pg.set_defaults(func=cmd_gate) + + pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason") + pr.add_argument("slug", nargs="?", default=None) + # --to / --reason are validated in-body (not argparse choices) so the named reject + # codes fire (reopen_target_invalid / reopen_reason_required), not a bare exit-2. + pr.add_argument("--to", default=None, help="target phase (specify..observe)") + pr.add_argument("--reason", default="", help="why the task is reopened (required, non-empty)") + pr.set_defaults(func=cmd_reopen) + + ps = sub.add_parser("stage", help="set the project stage") + ps.add_argument("stage", choices=STAGES) + ps.add_argument("--force", action="store_true", + help="override the →production roadmap guard (stage_no_roadmap)") + ps.set_defaults(func=cmd_stage) + + pst = sub.add_parser("status", help="print where the project is (resume point)") + pst.add_argument("--json", action="store_true", help="machine-readable JSON output") + pst.set_defaults(func=cmd_status) + + pck = sub.add_parser("check", help="read-only integrity check of the .add project") + pck.add_argument("--json", action="store_true", help="machine-readable JSON output") + pck.set_defaults(func=cmd_check) + + psg = sub.add_parser("sync-guidelines", + help="(re)write the ADD guideline block into AGENTS.md + CLAUDE.md") + psg.set_defaults(func=cmd_sync_guidelines) + + pgd = sub.add_parser("guide", help="print the one concrete next step for the active task") + pgd.add_argument("slug", nargs="?", default=None, help="task slug (default: active task)") + pgd.add_argument("--json", action="store_true", help="machine-readable JSON output") + pgd.set_defaults(func=cmd_guide) + + prp = sub.add_parser("report", + help="capture/render a milestone's what-happened report (read-only)") + prp.add_argument("milestone", nargs="?", default=None, + help="milestone slug for the rollup, OR a task slug to drill into " + "(smart: tried as a milestone first, then as a task); " + "default: active milestone") + prp.add_argument("task", nargs="?", default=None, + help="explicit `report <milestone> <task>`: render that task's " + "per-phase detail instead of the milestone rollup") + prp.add_argument("--json", action="store_true", + help="emit raw structured data (rollup -> report_data dict; " + "drill -> task_phases list of 7 phase dicts)") + prp.add_argument("--plain", action="store_true", + help="ASCII, no color, fixed width (pipe / CI / screen-reader safe)") + prp.add_argument("--decide", action="store_true", + help="decision-point digest: what needs the human's judgment NOW " + "(task -> decision digest; milestone -> DECIDE NEXT only; " + "bare -> the active task)") + prp.set_defaults(func=cmd_report) + + pdt = sub.add_parser("deltas", + help="read-only report: open lessons learned grouped by competency") + pdt.add_argument("--json", action="store_true", help="machine-readable JSON output") + pdt.set_defaults(func=cmd_deltas) + + pgr = sub.add_parser("graduation-report", + help="read-only: gather the MVP loop's evidence (deltas · waivers · RETROs · " + "residue · coverage gaps) for a graduation interview — gathers, never judges") + pgr.add_argument("--json", action="store_true", help="emit the frozen JSON facts interface") + pgr.add_argument("--plain", action="store_true", help="ASCII/pipe-safe text (output is plain by default)") + pgr.set_defaults(func=cmd_graduation_report) + + pau = sub.add_parser("audit", + help="read-only: verify recorded human decision points left well-formed records " + "(exit 1 on findings — the CI enforcement gate)") + pau.add_argument("--json", action="store_true", help="machine-readable JSON output") + pau.set_defaults(func=cmd_audit) + + ppj = sub.add_parser("project", help="print .add/PROJECT.md (the read-first foundation)") + ppj.set_defaults(func=cmd_project) + + return p + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + args.func(args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.add/tooling/templates/CONVENTIONS.md.tmpl b/.add/tooling/templates/CONVENTIONS.md.tmpl new file mode 100644 index 000000000..cc68a9b57 --- /dev/null +++ b/.add/tooling/templates/CONVENTIONS.md.tmpl @@ -0,0 +1,8 @@ +# CONVENTIONS (living documentation — set once, kept for the whole project) + +Language/framework: <e.g. Python 3.12 / FastAPI> +Folders: .add/tasks/<slug>/{TASK.md, tests/, src/} +Naming: <file case>, <type case>, verbs for functions +Lint/format: <tools>, enforced in CI +Errors: machine-readable error codes (string enums), never free text +Architecture: <layering and dependency rules> diff --git a/.add/tooling/templates/GLOSSARY.md.tmpl b/.add/tooling/templates/GLOSSARY.md.tmpl new file mode 100644 index 000000000..f5eca3b0d --- /dev/null +++ b/.add/tooling/templates/GLOSSARY.md.tmpl @@ -0,0 +1,26 @@ +# GLOSSARY (one name per concept — used everywhere: specs, contracts, code) + +<term>: <definition> + +# ADD method vocabulary (domain-standard names; bridges to legacy terms) +GOAL: the one durable outcome a project (and each milestone) runs toward — the loop's orientation anchor, declared as the lowercase `goal:` line in PROJECT.md / MILESTONE.md and surfaced by status/guide every session; distinct from a task's §1 Must (a single required behavior, not the whole-project outcome). +deep verify: the deepened Verify evidence (v20) required beyond passing tests — for a task that produced code, that every new symbol is referenced (wiring) and no new dead/unused code exists; for prose/non-code, a recorded no-skim semantic read; which path applies is resolver-judged and the engine never classifies (a rubric, not add.py). +onboarding: the install -> first-milestone path (formerly "on-ramp"). +primary flow: the solid forward path of the flow diagram — a phase starts only when its input exists (formerly "forward spine"). +cross-cutting concern: a concern running through every step rather than being one step — security, testing, observability, cost (formerly "spine / continuous concern"). +working state: everything an agent loads each session — skill router, active phase, PROJECT/MILESTONE/TASK, state.json (formerly "state surface"). +audit trail: the reference record read by people, never auto-loaded into agent context (formerly "story surface"). +method rationale: the why behind every rule — the AIDD book, loaded on demand, never duplicated (formerly "trust layer"). +failing-first suite: the test suite written before code, confirmed red for the right reason — a missing implementation (formerly "red safety net"). +non-functional review: the deliberate post-evidence check of what tests rarely catch — concurrency, security, architecture (formerly "blind-spot checks"). +change scope: the files a locked run may and may not touch (formerly "touch-boundary"; the <touch_boundary> XML prompt tag keeps its name). +automated quality gate: the evidence-based Verify resolver under autonomy auto — may auto-PASS on complete evidence; security always escalates (formerly "evidence auto-gate"). +autonomy level: the per-task Verify resolver setting — auto (default) or conservative; declared in the TASK.md header, human-reviewed at the freeze (formerly "autonomy dial"). +living documentation: the durable project artifacts — conventions, glossary, frozen contracts — that outlive any particular code (formerly "survivor layer"). +scope level: the granularity a decision lives at — intake level (request -> versioned scope), milestone level, setup/foundation level, task level (formerly "altitude"). +baseline approval: the one human gate that freezes the AI-drafted foundation, first scope, and first contract together — runs as `add.py lock` (formerly "the lock-down"). +lesson learned: a single learning a loop produces, tagged by the competency it improves — the `- [DDD · open]` grammar and deltas.md/`add.py deltas` machine names stay (formerly "competency delta"). +lowest-confidence flag: the AI's ranked declaration of the 1–2 points most likely to be wrong in what a human is asked to approve — each with why + cost-if-wrong; the ⚠ glyph keeps its name as the machine marker (formerly "least-sure flag"). +decision point: a stop for human judgment — the contract-freeze approval, an escalated verify gate, intake confirmation, milestone close; the machine names seam (--json owner enum, decide key) and seam-audit (CI job) keep their names (formerly "seam"). +retrospective consolidation: gathering confirmed lessons learned at milestone close and writing them append-only into the versioned foundation — human-confirmed, never self-approved; the machine names fold.md, the folded status, and add.py deltas keep their names (formerly "the fold / fold ritual"). +specification bundle: a task's spec, scenarios, contract, and failing tests drafted as one piece and approved by a person once at the contract freeze (formerly "the one-approval front"). diff --git a/.add/tooling/templates/MILESTONE.md.tmpl b/.add/tooling/templates/MILESTONE.md.tmpl new file mode 100644 index 000000000..032285f86 --- /dev/null +++ b/.add/tooling/templates/MILESTONE.md.tmpl @@ -0,0 +1,26 @@ +# MILESTONE: {{title}} + +goal: {{goal}} +rationale: <why this scope — the confirmed intake classification (bucket + reason)> +stage: {{stage}} · status: active · created: {{date}} + +> SDD living doc for this milestone. Keep it THIN: breadth, shared decisions, and +> exit criteria only — per-task detail lives in each `.add/tasks/<slug>/TASK.md`, +> written just-in-time. Update this doc whenever a task reveals a milestone gap. + +## Scope +In: <what this milestone delivers> +Out: <explicitly deferred — the anti-scope-creep list> + +## Shared decisions & glossary deltas (living — every task must honor these) +- <cross-cutting rule, named from GLOSSARY.md> + +## Shared / risky contracts (freeze these first) +- <contract name> -> owning task <slug> + +## Tasks (breadth-first decomposition; detail lives in each TASK.md) +- [ ] <slug> depends-on: none — <one line> +- [ ] <slug> depends-on: <slug> — <one line> + +## Exit criteria (observable; map each to the task that delivers it) +- [ ] User can <observable behavior> (← <slug>) diff --git a/.add/tooling/templates/MODEL_REGISTRY.md.tmpl b/.add/tooling/templates/MODEL_REGISTRY.md.tmpl new file mode 100644 index 000000000..0d42b79b2 --- /dev/null +++ b/.add/tooling/templates/MODEL_REGISTRY.md.tmpl @@ -0,0 +1,6 @@ +# MODEL_REGISTRY (which AI wrote this project — for reproducibility & audit) + +Model: <name> +Version: <version/date> +Adopted: {{date}} +Notes: re-run the playbook golden-cases before changing this. diff --git a/.add/tooling/templates/PROJECT.md.tmpl b/.add/tooling/templates/PROJECT.md.tmpl new file mode 100644 index 000000000..a3e592bb1 --- /dev/null +++ b/.add/tooling/templates/PROJECT.md.tmpl @@ -0,0 +1,43 @@ +# PROJECT — living documentation (cross-milestone context) + +> The durable foundation that outlives every milestone and feeds context into each +> TDD⇄ADD loop. Read this FIRST in any session. Keep it lean — one screen, not a +> manual. Map to the AIDD diagram: Domain = DDD · Spec = SDD (living document) · +> UI/UX = UDD. When a loop reveals a gap here, come back and update this file — +> that is the re-entrant arrow from the engine down to the foundation. + +slug: {{project}} · stage: {{stage}} · updated: {{date}} +goal: <the one durable outcome this whole project runs toward — set this at setup; status/guide surface it every session> + +--- + +## Domain (DDD) — the language and the boundaries +<!-- One name per concept (the GLOSSARY holds the full term list). What are the + core entities, the bounded contexts/modules, and the invariants that MUST + always hold? This is the ubiquitous language the spec and code share. --> +- Core concepts: +- Bounded contexts / modules: +- Invariants that must always hold: + +## Spec / Living Document (SDD) — what we are building, now +<!-- The spec is a living document, not a frozen plan. This section POINTS to the + active milestone + frozen contracts; it does not duplicate them. --> +- Active milestone → `.add/milestones/<slug>/MILESTONE.md` (see `add.py status`) +- Frozen contracts (living docs): the contracts other work builds against. +- Settled vs still open: <frozen contracts · open questions> + +## Users (UDD) — UI/UX: design before code +<!-- UI/UX-Driven Development: users use the interface, not the spec. Capture the + experience BEFORE code. Per-feature design detail lives in a DESIGN.md / + clickable prototype; keep here the cross-cutting UX stance + the source of truth. + For a no-UI project (CLI / library / service), say so and keep this short — + the "interface" is the command surface, API shape, or output it produces. --> +- Primary users & the jobs they hire the product for: +- Core user flows (happy + key alternative paths): +- UI states every screen handles: loading · empty · error · success +- Design source of truth → DESIGN.md / design system / clickable prototype + +## Key Decisions (append-only) +| date | decision | why | outcome | +|------|----------|-----|---------| +| {{date}} | (first decision) | | | diff --git a/.add/tooling/templates/TASK.md.tmpl b/.add/tooling/templates/TASK.md.tmpl new file mode 100644 index 000000000..6bcfb2a32 --- /dev/null +++ b/.add/tooling/templates/TASK.md.tmpl @@ -0,0 +1,139 @@ +# TASK: {{title}} + +slug: {{slug}} · created: {{date}} · stage: {{stage}} +phase: specify <!-- specify -> scenarios -> contract -> tests -> build -> verify -> observe -> done --> +<!-- high-risk/method-defining scope? declare `risk: high` on the slug line above and lower + the autonomy level with `autonomy: conservative` — the engine refuses an unguarded completion + (`unguarded_high_risk_auto`, run.md guard). A comment is never a declaration. --> + +> One file = one task. Fill sections top-to-bottom; the `add` skill drives each phase. +> When a phase is unclear, read its book chapter in `.add/docs/` (linked per section). +> The phase marker above is the single source of truth — keep it in sync via `add.py phase`. + +--- + +## 1 · SPECIFY — the rules ▸ docs/03-step-1-specify.md + +Feature: <name> +Framings weighed: <chosen> (chosen) · <alternative> · <alternative> +Must: +<must> + - <required behavior> +</must> +Reject: +<reject> + - <bad input / situation> -> "<error_code>" +</reject> +After: +<after> + - <state that is true once it succeeds> +</after> +Assumptions — lowest-confidence first: +<assumptions> + ⚠ <the one assumption most likely to be wrong> — lowest confidence because <why>; if wrong: <cost> + - [ ] <next assumption, ranked> — confirm or deny; never carry an open one forward +</assumptions> + +<!-- EXIT: every rule stated, every rejection named; assumptions ranked lowest-confidence first, the top one or two ⚠-flagged with why + cost (or, for trivial scope, an honest "none material" that still names the single biggest risk). --> + +--- + +## 2 · SCENARIOS — pass/fail cases ▸ docs/04-step-2-scenarios.md + +<scenarios> + +```gherkin +Scenario: <short name> + Given <starting situation> + When <action> + Then <expected result> + And <what must remain unchanged> # required for every rejection +``` + +</scenarios> + +<!-- EXIT: one scenario per Must AND per Reject; each result is observable. --> + +--- + +## 3 · CONTRACT — freeze the shape ▸ docs/05-step-3-contract.md + +``` +<METHOD> <path> body: { <fields> } + 200 -> { <success fields> } + 4xx -> { error: "<code>" | "<code>" } +Schema: <tables/fields touched, and access pattern> +``` + +Status: DRAFT +<!-- The freeze IS the one approval — lead it with the bundle's lowest-confidence flag: the 1–2 + points most likely wrong across the whole bundle, tagged [spec|scenario|contract|test], each + with why + cost (the §1 ⚠ assumptions feed it; a flag may point at a scenario or the contract + too — see run.md). Approved -> Status: FROZEN @ vN — approved by <name>. Changing a frozen + contract = change request back to SPECIFY. + EXIT: frozen + every spec rejection has a contracted response + names match GLOSSARY + the + bundle's lowest-confidence flag was surfaced at the freeze (or an honest "none material"). --> + +--- + +## 4 · TESTS — failing-first suite (red) ▸ docs/06-step-4-tests.md + +Coverage target: <e.g. 90%> +Plan (one test per scenario, asserting behavior not internals): +<test_plan> + - test_<scenario>: arrange <Given> / act <When> / assert <Then> + assert <unchanged> +</test_plan> + +Tests live in: `./tests/` · MUST run red (missing implementation) before Build. +<!-- declare paths as backticked tokens on this line: `./…` = this task dir · + a token with "/" = project root · a bare name = sibling of the previous + token's dir · a directory counts its *.py files (non-recursive); reports + mark declared counts with † · anything resolving outside the project root counts 0 --> + +<!-- EXIT: one test per scenario; suite red for the RIGHT reason; target recorded. --> + +--- + +## 5 · BUILD — AI writes code ▸ docs/07-step-5-build.md + +Safety rule (feature-specific): <e.g. debit+credit in one atomic transaction> +Code lives in: `./src/` +Constraints: do NOT change any test or the contract; allow-list packages only; ask if unclear. + +<!-- EXIT: all green; coverage held; no test/contract touched; no unlisted dependency. --> + +--- + +## 6 · VERIFY — evidence + non-functional review ▸ docs/08-step-6-verify.md + +- [ ] all tests pass +- [ ] coverage did not decrease +- [ ] no test or contract was altered during build +- [ ] concurrency / timing of the risky operation is safe +- [ ] no exposed secrets, injection openings, or unexpected dependencies +- [ ] layering & dependencies follow CONVENTIONS.md +- [ ] a person reviewed and approved the change + +### Deep checks — do not skim (fill the path that applies; the resolver judges which) +- [ ] WIRING (code) — every new symbol is referenced; record where / how confirmed +- [ ] DEAD-CODE (code) — no new unused or orphaned symbol introduced +- [ ] SEMANTIC (prose / non-code) — read in full, not skimmed: <what read · what confirmed> + +### GATE RECORD +Outcome: <PASS | RISK-ACCEPTED | HARD-STOP> +If RISK-ACCEPTED -> owner: <name> · ticket: <link> · expires: <date> (never for a security gap) +Reviewed by: <name> · date: <date> + +<!-- A security finding is ALWAYS HARD-STOP. Record exactly one outcome — no silent pass. --> + +--- + +## 7 · OBSERVE — feed the next loop ▸ docs/09-the-loop.md + +Watch (reuse scenarios as monitors): <error rate / per-rejection rate / latency> +Spec delta for the next loop: <what production taught you> + +### Competency deltas +What did this loop teach the foundation? One line each, tagged by competency +(`DDD · SDD · UDD · TDD · ADD`), status `open`, with evidence. See the `add` skill's `deltas.md`. +<!-- e.g. - [DDD · open] the model missed multi-tenancy (evidence: scenario_x failed) --> diff --git a/.add/tooling/templates/dependencies.allowlist.tmpl b/.add/tooling/templates/dependencies.allowlist.tmpl new file mode 100644 index 000000000..d19ce45e9 --- /dev/null +++ b/.add/tooling/templates/dependencies.allowlist.tmpl @@ -0,0 +1,2 @@ +# dependencies.allowlist — one package per line; CI rejects anything not listed. +# Defeats the AI "invented a plausible package name" supply-chain risk. diff --git a/.claude/skills/add/SKILL.md b/.claude/skills/add/SKILL.md new file mode 100644 index 000000000..53a12aa56 --- /dev/null +++ b/.claude/skills/add/SKILL.md @@ -0,0 +1,147 @@ +--- +name: add +description: >- + ADD (AI-Driven Development) — a minimal, state-tracked workflow for building + software where the AI writes the code and the human owns direction and + verification. Drives every feature through one lean TASK.md: Specify → + Scenarios → Contract → Tests → Build → Verify → Observe, with red/green TDD + built in. Use this skill whenever working in a repo that has a `.add/` + directory, when the user says "add", "start a task", "next phase", "specify + this feature", "ADD method", or "AI-driven development", or when scaffolding a + new feature and you want spec/tests-first discipline instead of vague-prompt + coding. Also use it to resume work across sessions (it reads `.add/state.json` + so you never re-read the whole repo). +--- + +# ADD — the orchestration engine + +You are the orchestrator. ADD keeps the AI fast *and* safe by fixing direction +(spec, scenarios, contract, failing tests) **before** the build, and trusting +the result through passing evidence rather than a plausible-looking diff. + +**One file = one task.** Each feature lives in a single `.add/tasks/<slug>/TASK.md` +with seven sections. You fill them top to bottom; the Python tool tracks where +you are so context never rots across sessions. + +## Always start here (orient — do not skip) + +Run the tool to find the resume point instead of re-reading the repo: + +```bash +python3 .add/tooling/add.py status +``` + +- **No `.add/state.json` yet** (a fresh install drops tooling + docs but does *not* init — so `status` says + `no .add/ project found`) → enter **autonomous setup**: YOU run init yourself — + `add.py init --name "<inferred>" --stage <picked> --await-lock` (don't tell the human to) — then read + `phases/0-setup.md` and draft the foundation + first scope + first contract through to the human baseline approval. +- **A task is active** → open `.add/tasks/<active>/TASK.md`, look at its `phase:` + marker, and read the matching `phases/<n>-<phase>.md`. Work *only* that phase. +- **No active task** → first SIZE the request (see Intake below), then create the + right scope: `python3 .add/tooling/add.py new-task <slug> --title "..."`. + +## Intake — size a request before creating scope + +When the user brings a raw request, classify it BEFORE making a milestone or task: +read `intake.md` and place it in exactly one bucket — `new-major` · `sub-milestone` +· `task` · `change-request` — then propose `{ bucket, rationale, command }` and let +the human confirm. This is the intake level (request → versioned scope); see +`intake.md` for the rubric, the tie-break order, and worked examples. A question or +unsharp intent? **Interview before you size** — explore and suggest first (`intake.md`). + +Once a request is classified `new-major`/`sub-milestone`, drafting the actual +`MILESTONE.md` (goal · scope · exit criteria · breadth-first tasks) is the second +half of intake: read `scope.md` for how to fill it well, the per-outcome behavior, +and the confirm-before-create rule. You propose the draft; the human confirms. + +## The flow and which file to load + +Load the phase guide **only for the phase you are in** (progressive disclosure): + +| Phase | Guide | Produces (TASK.md section) | Who leads | +|-------|-------|----------------------------|-----------| +| setup | `phases/0-setup.md` | `.add/` + living docs + first §1–§3 + `SETUP-REVIEW.md` | AI drafts → **human locks** (the baseline approval) | +| specify | `phases/1-specify.md` | §1 rules + ranked lowest-confidence flag | AI drafts (co-specify)† | +| scenarios | `phases/2-scenarios.md` | §2 Given/When/Then | AI drafts† | +| contract | `phases/3-contract.md` | §3 frozen shape | AI drafts → **human approves once** (the decision point)† | +| tests | `phases/4-tests.md` | §4 + red suite in `tests/` | AI drafts† | +| build | `phases/5-build.md` | code in `src/`, tests green | **AI** | +| verify | `phases/6-verify.md` | §6 checks + gate record | **AI auto-gates on evidence**; human on residue/security‡ | +| observe | `phases/7-observe.md` | §7 spec delta | human + AI | + +† **The specification bundle (v7).** §1–§4 are one bundle; the human gives **one approval at the +contract freeze** (the decision point), presented lowest-confidence-first. See `run.md`. +‡ **Verify auto-gate (v6–v7).** Under `autonomy: auto` (the default) a run may auto-PASS on +complete evidence — recorded as *auto-resolved*, an explicit PASS, not a skip. **Security always +escalates** (HARD-STOP); so do concurrency / architecture residue and `conservative` autonomy. +See `run.md`. + +Whenever you present a decision point to the human in chat (intake · bundle approval · gate · +milestone close), follow `report-template.md` — open with the ARC (goal · done · plan, +engine-sourced), then SUMMARY → DECISION → ⚠ FLAGS → EVIDENCE → NEXT, show-before-ask, never +pre-stamp a decision point — and the question is a summary, never the artifact. + +In **observe**, also emit **lessons learned** — learnings tagged by which of the five +(`DDD · SDD · UDD · TDD · ADD`) they improve — so the foundation self-improves across loops. +You write them as `open`; the human consolidates them into `PROJECT.md`. Read `deltas.md` for the +grammar and the status lifecycle. At milestone close (or on demand), run the retrospective consolidation that +gathers confirmed deltas into a versioned foundation — read `fold.md`. + +## Beyond the bundle — load on demand + +Once **§3 CONTRACT is FROZEN**, the build→verify half is a dynamic, auto-gated run +(`autonomy: auto` default, lowered to `conservative` for a human gate) — read `run.md`. To +pipeline several ready tasks behind their own frozen contracts, read `streams.md`. + +When a milestone's tasks are all done but its **goal** (the `MILESTONE.md` exit criteria) is not +yet met, `milestone-done` holds the milestone open — read `loop.md` for the dynamic loop that turns +open deltas + extras into the next tasks, proposed by you and confirmed by the human, until the goal is met. + +When `add.py status` prints **`MVP covered → propose graduation`** (every milestone done AND the +stage-goal-criteria all `[x]`), the project is ready to graduate its stage — read `graduate.md` for the +orchestration: gather `graduation-report` analytics → co-specify interview → draft ≥1 production +milestone → human confirm → then (and only then) `stage production`. The flip is guarded +(`stage_no_roadmap`) and is the FINAL step — never a bare label change. + +## Non-negotiable rules (from the method) + +<constraints> +1. **Direction before speed.** Never start Build until §1–§4 exist and tests are red. +2. **Trust evidence, not inspection.** A feature is trusted because its tests pass + and the non-functional risks (concurrency, security, architecture) were checked — not + because the code reads plausibly. +3. **Never weaken a test or edit a frozen contract to make the build pass.** That + inverts the method. A real change is a *change request* back to Specify. +4. **No silent skips.** Every Verify ends in exactly one recorded outcome: + `PASS`, `RISK-ACCEPTED` (signed, non-security only), or `HARD-STOP`. A security + finding is always `HARD-STOP`. +5. **Ask, don't guess.** If a requirement is unclear, stop and ask the user. +</constraints> + +## Advancing + +After a phase's exit gate is met, advance the state (this also syncs the marker +inside TASK.md): + +```bash +python3 .add/tooling/add.py advance # next phase of the active task +python3 .add/tooling/add.py gate PASS # at verify: records PASS, marks done +python3 .add/tooling/add.py use <slug> # switch the active task (e.g. across parallel streams) +``` + +## Depth by stage + +The steps never change; their depth does. Read the stage from `add.py status`: + +- **prototype** — run light; code is throwaway; design/experience is the point. +- **poc** — run contract/tests/build deeply on the single riskiest slice only. +- **mvp** — full flow, narrow scope, light observation. +- **production** — every step at full rigor + the observe loop. Reach it via the graduation + orchestration (`graduate.md`) when status shows `MVP covered → propose graduation`, never a bare + `stage production` flip — the transition is guarded behind a human-confirmed roadmap. + +## The method rationale + +The full method (the *why* behind every rule) is the AIDD book in `.add/docs/`. +When a phase decision is genuinely unclear, read the linked chapter — each phase +guide points to its chapter. Do not duplicate the book here; load it on demand. diff --git a/.claude/skills/add/adopt.md b/.claude/skills/add/adopt.md new file mode 100644 index 000000000..5335d3ff8 --- /dev/null +++ b/.claude/skills/add/adopt.md @@ -0,0 +1,67 @@ +# Adopt — map an existing repo into the foundation (silent) + +When ADD is pointed at a repo that already has code, onboarding is **silent**: the code +answers the questions a greenfield interview would ask, so you read it rather than ask. +This is the **brownfield path** of setup (the greenfield path keeps the 4-lens interview — +see `phases/0-setup.md`). You fill the living-documentation files from evidence, then stop at the one +human gate: the **baseline approval** (`add.py lock`). + +## The signal — and arming the gate + +Enter a brownfield repo with `--await-lock`: + +```bash +python3 .add/tooling/add.py init --await-lock +``` + +`--await-lock` does two things. It seeds an **unlocked** setup, which *arms the baseline-approval gate* +— the engine then refuses a second task, crossing into build, and recording a gate until you +`lock`. And init, being brownfield-aware, prints a line that begins: + +``` +brownfield: existing code detected — the `add` skill maps it into your foundation … +``` + +That line is your cue to run this guide. **Always use `--await-lock` for brownfield onboarding**: +a plain `init` writes no setup and is grandfathered-locked, so its gate never arms *and* the +closing `lock` below would refuse with `already_locked`. The engine only *detects* the existing +code (a mechanical fact); it never reads or fills it — interpreting it is your job. + +## The silent mapping + +Fill each living-doc file in `.add/` from what the code actually shows — **ask nothing**: + +| Living doc | Read it from | +|----------|--------------| +| `PROJECT.md` (foundation) | the domain nouns, entry points, the README, the first milestone the code implies | +| `CONVENTIONS.md` | the languages, folder layout, naming, lint config, error style already in the tree | +| `GLOSSARY.md` | the recurring names in modules, models, and public APIs (one name per concept) | +| `MODEL_REGISTRY.md` | leave the active model record; note any AI-authored code you can detect | +| `dependencies.allowlist` | the manifests already in the repo (package.json, pyproject, go.mod, …) | + +Two rules that never bend: + +<constraints> +1. **Never clobber a living doc.** `init` already skips any living-doc file that exists; if a human + already wrote `PROJECT.md`, you READ it, you do not overwrite it. Add, never replace. +2. **Tag every drafted decision `evidence-grounded` vs `guessed`.** A line you read from the + code is *evidence-grounded* (cite the file). A line you inferred because the code was silent + is *guessed*. The human's single baseline approval is only honest if they can see which is which — + the guesses are what they actually need to check. (The tags feed `SETUP-REVIEW.md`.) +</constraints> + +## Where it ends — the baseline approval + +Brownfield onboarding draws no per-step approvals. You map the foundation, then draft the +first milestone's scope and the first task's candidate specification bundle exactly as greenfield does, and +present it all at **one** human gate. The human reviews the decisions (lowest-confidence / `guessed` +first) and confirms in conversation; you run the lock with their name: + +```bash +python3 .add/tooling/add.py lock --by "<name>" +``` + +`lock` freezes the foundation + scope + first contract in one atomic write and opens the build. +Until it is run, the engine refuses a second task, crossing into build, and recording a gate — +so nothing is built on an unreviewed map. That gate is the only thing brownfield onboarding asks +of a human; everything before it, you did from the code. diff --git a/.claude/skills/add/deltas.md b/.claude/skills/add/deltas.md new file mode 100644 index 000000000..d8b975fce --- /dev/null +++ b/.claude/skills/add/deltas.md @@ -0,0 +1,81 @@ +# Lessons learned — how each loop sharpens the foundation + +A **lesson learned** is a single learning a task produces, tagged by which of ADD's five +competencies it improves. You write deltas in a task's **OBSERVE** phase; later, the +`foundation-update-loop` gathers the confirmed ones and consolidates them into a versioned `PROJECT.md`. +This is how `DDD · SDD · UDD · TDD · ADD` stop being write-once and start converging. + +You (the AI) **emit** deltas as `open`. Only the **human** moves a delta to `folded` or `rejected` +(consolidating into the foundation is judgment — see the verify/observe decision point). You never self-approve a consolidation. + +## The grammar (frozen) + +Each delta begins on its own **tag line**; the learning may wrap onto continuation lines: + +``` +- [<COMPETENCY> · <status>] <learning> (evidence: <pointer>) +``` + +- `<COMPETENCY>` — exactly one of the five (below). +- `<status>` — `open` | `folded` | `rejected`. A **newly emitted delta is `open`**. +- `<learning>` — the insight ("the domain model missed multi-tenancy"). It may run past one line; + the `- [COMPETENCY · status]` tag line must come **first**, and the `(evidence: …)` clause must + **close** the delta (on its last line). +- `(evidence: …)` — **required**, non-empty: a failing scenario, a production signal, a review + note. No evidence → it is an opinion, not a delta. + +A long learning may wrap — the lint (`add.py check`) joins continuation lines, so this is **one** +delta, not two: + +``` +- [SDD · open] the export endpoint must reject a tenant-scoped token used cross-tenant, + returning `forbidden` (not `not_found`) (evidence: scenario_cross_tenant_export failed) +``` + +## The five competencies (pick exactly one per delta) + +| tag | competency | a delta here means you learned something about… | +|-----|------------|--------------------------------------------------| +| `DDD` | Domain | the domain model — an entity, rule, or boundary the spec assumed wrong | +| `SDD` | Spec | what the feature must do / must reject — a missing or wrong requirement | +| `UDD` | UI/UX | the user-facing shape — a flow, affordance, or wording that misled | +| `TDD` | Test | how we prove correctness — a missing scenario, a flaky or hollow test | +| `ADD` | AI/build | how the AI builds — a harness, prompt, or convention that helped or hurt | + +If a learning seems to touch two, ask "which competency, once updated, would have PREVENTED this?" +That is its home. Split genuinely separate learnings into separate deltas; never tag one twice. + +## Status lifecycle + +``` +emit (OBSERVE) human review (foundation-update-loop) + open ───────────▶ folded (the learning is merged into PROJECT.md; version bumps) + └──────────▶ rejected (considered and deliberately NOT consolidated — the trail is kept) +``` + +An `open` delta is a pending signal. `folded` and `rejected` are both human decisions; a `rejected` +delta is left in place (not deleted) so "we saw this and chose not to act" stays auditable. + +## Reject codes (well-formedness — you are the first check, the human is the backstop) + +There is no engine validator yet, so before you record a delta, self-check it: + +<reject_codes> +- `unknown_competency` — the tag is missing or not one of `DDD · SDD · UDD · TDD · ADD`. Fix the tag. +- `no_evidence` — the `(evidence: …)` pointer is missing or empty. Add the proof, or drop the line. +- `unknown_status` — the status is not `open | folded | rejected`. A fresh delta is `open`. +</reject_codes> + +## Worked example + +A task that built a tenancy feature finished its OBSERVE phase with: + +``` +- [DDD · open] the account model conflated org and workspace (evidence: scenario_cross_tenant_read failed) +- [TDD · open] no scenario covered a deleted tenant's dangling sessions (evidence: review note, PR thread) +- [ADD · open] the scaffold's allow-list missed the tenancy lib, slowing build (evidence: build log retry) +``` + +Three learnings, three competencies, each with a pointer. At the next foundation update the human +consolidated the DDD and TDD deltas into `PROJECT.md` (→ `folded`) and rejected the ADD one as a one-off +(→ `rejected`). The foundation got sharper; nothing was silently lost. diff --git a/.claude/skills/add/fold.md b/.claude/skills/add/fold.md new file mode 100644 index 000000000..12433a337 --- /dev/null +++ b/.claude/skills/add/fold.md @@ -0,0 +1,68 @@ +# Consolidating deltas — how the foundation self-improves + +This **closes the loop**. `deltas.md` lets a task EMIT learnings (`open` lessons learned in its +OBSERVE phase); the retrospective consolidation gathers the confirmed ones and writes them into a **versioned foundation**, +so `DDD · SDD · UDD · TDD · ADD` sharpen across milestones instead of drifting. + +You (the AI) **gather and propose**; the **human confirms**; you then write the **append-only** consolidation. +You never self-approve a consolidation — consolidating is judgment (see the verify/observe decision point). + +## When to consolidate + +At **milestone close** (the natural "version bump to the foundation"), or **on demand** when open +deltas have piled up. This is a convention, not a command — there is no `add.py fold`; the consolidation +lives here so the engine stays judgment-free. + +## The ritual + +1. **Gather** — scan every task's §7 OBSERVE block for lesson-learned lines still `open` (`add.py deltas` reads them by the machine heading). +2. **Group** — bucket them by competency (`DDD · SDD · UDD · TDD · ADD`). +3. **Propose** — for each, draft the exact foundation edit (see routing) and show the human. +4. **Confirm** — the human accepts or declines each delta. No write happens without this. +5. **Write** — append the accepted edits, flip each delta's status, and bump the version. + +## Consolidation routing (every competency has a home) + +| competency | consolidates into | how | +|------------|-----------|-----| +| `DDD` | `PROJECT.md` §Domain (DDD) | refine/append a model bullet | +| `SDD` | `PROJECT.md` §Spec / Living Document (SDD) | refine/append a settled-vs-open line | +| `UDD` | `PROJECT.md` §Users (UDD) | refine/append a UX line | +| `TDD` | `CONVENTIONS.md` | append a testing convention (no PROJECT.md section — it is the engine) | +| `ADD` | `CONVENTIONS.md` | append a build/harness convention (likewise the engine) | + +**Every** consolidation — whatever the competency — ALSO appends one row to `PROJECT.md` **§Key Decisions** +(date · decision · why · outcome): the universal, auditable trail of what the foundation learned. + +## Status transitions & version + +- on **confirm**: the delta moves `open` → `folded` (and its edit is appended to the routed target). +- on **decline**: the delta moves `open` → `rejected` and is **left in place** — never deleted — + so "we considered this and chose not to act" stays auditable. +- a consolidation is **append-only**: it adds bullets/rows; it never silently rewrites existing foundation text. +- each consolidation session **bumps** the `foundation-version:` marker in `PROJECT.md` by one (monotonic int). + +## Reject codes (the AI is first check, the human the backstop) + +<reject_codes> +- `no_open_deltas` — nothing is `open` anywhere. The ritual is a no-op; do **not** bump the version. +- `unconfirmed_fold` — a write was attempted without recorded human confirmation. The AI proposes; + it never self-approves one. Stop and get confirmation. +- `unroutable_delta` — a delta's competency is not one of the five, so it has no consolidation target. Fix the + delta (it is malformed per `deltas.md`) before consolidating. +</reject_codes> + +## Worked example (from this repo's own history) + +The `competency-deltas` task closed its OBSERVE with two deltas — the homeless ones, `TDD`/`ADD`, +which have no PROJECT.md section: + +``` +- [ADD · open] dogfood .add/tooling template can silently diverge from canonical (evidence: md5 mismatch this build) +- [TDD · open] structural tests guard canonical artifacts but not their dogfood twins (evidence: scope-loop note + this build) +``` + +At the next consolidation the human confirms both. Routing sends each to `CONVENTIONS.md` (a "sync the dogfood +tree + assert md5 parity" convention), appends a §Key Decisions row for each, flips them to `folded`, +and bumps `foundation-version` 1 → 2. The two competencies the foundation never tracked before now +have a home — which is exactly why v5 routes TDD/ADD to `CONVENTIONS.md`. diff --git a/.claude/skills/add/graduate.md b/.claude/skills/add/graduate.md new file mode 100644 index 000000000..bf8043936 --- /dev/null +++ b/.claude/skills/add/graduate.md @@ -0,0 +1,74 @@ +# Stage graduation — propose the move to production as a roadmap, never a flip + +A project does not become "production" because someone typed a new label. It graduates when +the MVP is genuinely covered AND a human-confirmed roadmap of production work exists. This guide +is the **4th scope level** — after setup (`phases/0-setup.md`), intake (`intake.md` / `scope.md`), +and the milestone loop (`loop.md`). It turns the bare `add.py stage` flip into the **final step** of +an analytics-driven, interview-led orchestration. + +You (the AI) **gather and propose**; the **human confirms and judges**; the engine only counts +tallies and enforces the floor. The engine never decides that the project is "ready" — that is +judgment, and it belongs to the interview. + +## The cue (what starts this) + +When every milestone is `done` AND the human's stage-goal-criteria in `PROJECT.md` are all `[x]`, +`add.py status` prints: + +``` + → MVP covered → propose graduation +``` + +That line is the trigger. Before both tallies complete, status is silent and nothing here applies +(a project with no stage-goal-criteria block behaves exactly as today — grandfathered, zero change). + +## The flow + +1. **Gather the analytics** — run `add.py graduation-report` (add `--json` to branch on it). It + clusters the whole MVP loop's evidence into five labeled record-sets: open deltas by competency · + open RISK-ACCEPTED waivers by expiry · RETRO records · verify residue · observe-loop coverage gaps. + It **gathers, never judges** — there is no readiness verdict to read; the records are what you + reason from. +2. **Co-specify interview** — synthesize *"what production means HERE"* WITH the human, using the + gathered records as the agenda (the residue to harden, the coverage gaps to monitor, the open + deltas to consolidate). This synthesis is the judgment the engine refuses to make. Interview to real confidence — + do not guess what "production-ready" means for this project. +3. **Draft the roadmap** — for each production outcome the interview surfaces, draft a production + milestone with the EXISTING command and goal-gate criteria: + `add.py new-milestone <slug> --stage production --goal "…"`, then write its exit criteria. The + roadmap is **≥1** milestone — the hardening work itself (SLOs, rollback tests, incident runbooks) + is what these milestones *contain*; this guide proposes them, it does not do them. +4. **Human confirms** — present the roadmap via `report-template.md`, opening with the ARC + (goal · done · plan): the stage-graduation goal, the MVP coverage that earns the move, and the + plan the production milestones lay out. The human accepts, edits, or declines each drafted + milestone. No milestone is created without this; nothing advances on a draft the human has not confirmed. +5. **Flip — the final step** — only now run `add.py stage production`. Because ≥1 production milestone + now exists, the guard passes and the transition is recorded. This is the orchestration's last act. + +## The floor (what the engine enforces) + +`add.py stage production` is **guarded**: it refuses with `stage_no_roadmap` (non-zero exit, state +byte-unchanged) when zero milestones have `stage: production`. The check is a **tally** — "does a +production-roadmap record exist?" — never a readiness judgment (gather-not-judge at stage level; +it mirrors the milestone goal-gate's `milestone_goal_unmet`). `--force` overrides it, preserving human +authority for grandfathered/edge cases; use it deliberately, not as the normal path. + +Scope: the guard is on the `→production` **transition** only. Flips to prototype/poc/mvp are the +existing bare flip, unchanged. `add.py init --stage production` is an explicit at-creation declaration +(the same authority as `--force`), not a transition — it is out of scope of the guard by design. + +## Invariants (never break these) + +- **The flip is the final step**, never called outside this confirmed-roadmap path. A bare flip with + no roadmap is the symptom this scope level removes. +- **The engine never auto-flips.** Every step here is human-confirmed; the engine gathers, counts, and + enforces the floor — it does not advance the stage on its own. +- **The flow is continuous, not cue-reentrant.** The moment you draft the first production milestone, + `status` stops printing the cue (the "every milestone done" tally breaks). That is expected — do NOT + re-await the cue after drafting; carry the flow straight through to confirm and flip. + +## Depth and reuse + +The same orchestration serves prototype→poc and poc→mvp; **mvp→production** is the rigorous proof +case (every step at full depth + the observe loop). At lower stages, run it light — the shape is the +same, the depth is less. diff --git a/.claude/skills/add/intake.md b/.claude/skills/add/intake.md new file mode 100644 index 000000000..992f2199d --- /dev/null +++ b/.claude/skills/add/intake.md @@ -0,0 +1,64 @@ +# Intake — size a request into versioned scope + +Before a task exists, ADD turns a raw request into correctly-sized, versioned scope. +This is the **intake level**: the per-task flow is phases 0–7; intake is the step +*before* a task — request → milestone or task. You (the AI) **propose**; the human +**confirms**. Never create scope without a confirmed proposal. + +## Interview before you size + +When the request arrives as a question, or its intent is not yet sharp enough to +place in one bucket: explore it WITH the user before classifying. Reflect the +intent you heard, name what seems in and out of scope, and offer 2–3 sized options +with your own recommendation. Only then emit `{ bucket, rationale, command }`. +`ask_human` stays the floor: when interviewing cannot sharpen the request, +reject — never guess a bucket. + +## The four buckets + +Classify every request into exactly ONE bucket: + +| Bucket | Decision test | Implied command | +|--------|---------------|-----------------| +| `new-major` | a new product theme/pillar no active milestone's goal covers | `add.py new-milestone vN` | +| `sub-milestone` | a slice of an EXISTING major theme, too big for one task | `add.py new-milestone vN-M` | +| `task` | fits within the ACTIVE milestone's stated scope | `add.py new-task <slug>` | +| `change-request` | modifies ALREADY-FROZEN scope (a frozen contract or a shipped promise) | `add.py phase specify\|contract <affected>` | + +**Tie-break order: the frozen-scope test runs FIRST, before the size test.** +First ask "does this change already-frozen scope?" → if yes, it is a `change-request` +(never re-size frozen work as new scope). Only if no, apply the size test: a new theme +→ `new-major`; a slice of a live theme → `sub-milestone`; fits the active milestone +→ `task`. + +## What you emit (the proposal) + +Present the proposal to the human via `report-template.md` — open with the ARC (goal · done · +plan): the goal this request serves, what is already covered, and the plan the chosen bucket sets up. + +For every request, emit ONE of: + +- **a classification** — `{ bucket, rationale, command }` — where `rationale` names WHY + (the theme, the slice, the fit, or the frozen scope touched) and `command` is the exact + `add.py …` from the table. The human confirms or overrides before you run it. +- **a rejection** — `{ reject, rationale }` — and you create nothing, emitting one of the closed set: + +<reject_codes> +- `ask_human` — too ambiguous/underspecified to size. Ask the human; never guess a bucket. +- `frozen_scope` — it changes frozen scope; route it as a `change-request` back to + SPECIFY/CONTRACT of the affected task — never spawn a parallel milestone that forks the truth. +- `split_required` — it spans more than one bucket; propose the SMALLEST set of correctly-sized + items, each with its own rationale; never force it into one milestone. +</reject_codes> + +When confirmed, record the `rationale` in the artifact you create or affect — the new +MILESTONE.md goal/body, the new TASK.md, or a note in the affected TASK.md — never in state.json. + +## Worked examples (from this project's own history) + +| request | bucket | rationale | +|---------|--------|-----------| +| give ADD a hosted web dashboard | new-major | a new product theme no active milestone's goal covers → a fresh major line (v5) | +| add the build corridor + tests-red-before-build | sub-milestone | a slice of the live v4 "self-driving" theme, too big for one task → v4-2 | +| expose owner/stop as --json | task | fits the active v4-1 (intake interface) scope → one task | +| guide --json phase/gate should be nullable | change-request | changes the FROZEN machine-state-json contract → reopen its CONTRACT, do not make a new milestone | diff --git a/.claude/skills/add/loop.md b/.claude/skills/add/loop.md new file mode 100644 index 000000000..7a1a78b73 --- /dev/null +++ b/.claude/skills/add/loop.md @@ -0,0 +1,59 @@ +# The dynamic loop — open deltas and extras become the next tasks + +A milestone is not done when its tasks are done — it is done when its **GOAL** is met. +This guide is the loop that drives a milestone toward that goal: turn what each task +leaves behind (open lessons, and work discovered but out of scope) into the next tasks, +and keep going until the exit criteria are all met. + +You (the AI) **gather and propose**; the **human confirms**; the existing `add.py new-task` +creates each one. The engine never decides what the next task is — that is judgment. + +## The goal-gate (what holds the loop open) + +`add.py milestone-done <slug>` REFUSES to close a milestone while its exit criteria are not +all met — it stops with `milestone_goal_unmet` and the milestone stays active. The exit-criteria +checkboxes in `MILESTONE.md` ARE the human's goal-met affirmation: the engine reads the +`- [x]`/`- [ ]` tally, it never judges whether the goal is met (the same trust model as reading a +recorded `PASS`). Checking the last box is the deliberate act that releases the gate. + +The gate fires only when criteria exist. A milestone with no exit-criteria checkboxes closes as +before — write criteria into `MILESTONE.md` if you want the goal-gate to hold the milestone open. + +`milestone-done` is the only way a milestone reaches `done`; `archive-milestone` and `compact` +both refuse a milestone that is not done. So the one gate is enough — there is no quiet way around it. + +## The loop + +When every task is done but the goal is not, `add.py status` shows +`goal not met (m/n exit criteria)` where it would otherwise prompt to archive. That is the cue: + +1. **Gather** the carried inventory: + - open lessons — `add.py deltas` (the §7 OBSERVE deltas still `open`); + - the planned-but-unscaffolded tasks — the plan-vs-state line in `add.py status`; + - any reopened task — one a deepened verify returned to the flow (see below). +2. **Propose** the next tasks: for each carried item worth doing now, draft a one-line task + (slug + title + why) and show the human. Group the trivial ones; do not propose noise. +3. **Confirm** — the human accepts, edits, or declines each. No task is created without this. +4. **Create** each accepted task — `add.py new-task <slug> --title "..."` — and run it through + the normal flow (specify → … → verify). +5. **Repeat** until the work the goal needs is done. +6. **Close** — when the goal is genuinely met, check the exit-criteria boxes in `MILESTONE.md`, + then `add.py milestone-done <slug>` succeeds (then consolidate the open deltas and archive). + Present the close via `report-template.md` — open with the ARC (goal · done · plan): the + milestone goal, the exit-criteria met that prove it, and the plan beyond the close. + +## Reopen is the verb; this loop is the trigger + +When a deepened verify (the no-skim wiring / dead-code / semantic check) finds a criterion unmet +on a task already marked done, `add.py reopen <task> --to <phase> --reason "..."` returns it to the +flow with a recorded reason and a reset gate. `reopen` is the recorded action; deciding WHEN to +fire it — because a goal criterion is unmet — is this loop's job. + +## The reactivation residual (deferred) + +A reopen fired inside the loop happens while the milestone is still **active** — the goal-gate held +it open, so it never reached done, and no reactivation is needed. The one residual — reopening a +task inside a milestone that was already closed — is surfaced by `add.py check` (a done milestone +with a live task reads as incoherent). Re-activating a closed milestone is **deferred**: resolve it +by hand for now (the loop's own design keeps in-flight milestones open), until a later task makes +milestone reactivation first-class. diff --git a/.claude/skills/add/phases/0-setup.md b/.claude/skills/add/phases/0-setup.md new file mode 100644 index 000000000..b931b7f16 --- /dev/null +++ b/.claude/skills/add/phases/0-setup.md @@ -0,0 +1,103 @@ +# Phase 0 — Setup (autonomous draft → one human baseline approval) + +Goal: point ADD at a repo and **you** draft the whole foundation — domain, first-milestone scope, +and the first task's contract — then hand the human exactly one decision: the **baseline approval**. Brownfield +is silent (the code answers the questions); greenfield keeps a short interview. Either way, the human's +only gate is `add.py lock`. This is the setup-level analog of a task's one-approval contract freeze. + +## 1 · Zero-touch entry — you run init yourself + +When there is no `.add/state.json`, do **not** tell the human to initialise — run it yourself. Infer the +project name and stage from the repo, and **arm the baseline-approval gate** with `--await-lock`: + +```bash +python3 .add/tooling/add.py init --name "<inferred from repo/dir>" --stage <prototype|poc|mvp|production> --await-lock +``` + +- `--await-lock` is **required** here: it seeds an *unlocked* setup, which arms the gate so the engine + refuses a second task / crossing into build / a `gate` until you `lock`. A plain `init` is + grandfathered-locked — its gate never arms, and the closing `lock` would error `already_locked`. +- name + stage are **your judgment** (read them from the dir name, README, manifests); the engine stays + mechanical. Pick the stage from the ambition you hear: throwaway → `prototype`, one risky slice → `poc`, + narrow-but-real → `mvp`, full rigor → `production`. + +`init` prints one of two things — **that is your branch**: +- a line starting `brownfield:` → there is existing code (go to **2a**); +- the greenfield closing (no `brownfield:`) → an empty repo (go to **2b**). + +## 2a · Brownfield — map it silently + +The code answers the questions a greenfield interview would ask, so **read it instead of asking**. Open +`adopt.md` and follow it: fill each living-doc file from the code, never clobber an existing one, and tag +every decision `evidence-grounded` (cite the file) or `guessed`. Ask the human **nothing** at this step. + +## 2b · Greenfield — the 4-lens interview (kept): co-specify at foundation level + +An empty repo has no code to read, so run the short interview. This is the **co-specify at foundation +level** move — the same diverge → converge → validate brainstorm a task's §1 uses (`phases/1-specify.md`), +lifted to the foundation. Ask the one load-bearing question per lens (diverge), draft the foundation +(converge), then rank where your confidence is lowest and show the top flag first (validate): + +| Lens | The one question that unblocks the section | +|------|--------------------------------------------| +| Domain (DDD) | The 3–5 core nouns, and the one invariant that must NEVER break? | +| Spec (SDD) | The first milestone's outcome — and what's explicitly NOT in v1? | +| Users (UDD) | The primary user and the one job they hire this for? (or "no UI — surface is X") | +| Decisions | What's already decided that you'd regret re-litigating? (first Key Decision row) | + +Ask only the live ones; skip what the request already answers. Rank your drafts lowest-confidence-first using the +one notation every scope level shares — `⚠ <assumption> — lowest confidence because <why>; if wrong: <cost>` — and +tag thin or inferred answers `guessed`. + +## 3 · Draft to the lock (both paths) + +1. **Fill the living documentation** (it outlives all code): `.add/PROJECT.md` (the foundation — Domain · Spec/active + milestone · UI/UX · Key Decisions, one screen), `CONVENTIONS.md`, `GLOSSARY.md`, `MODEL_REGISTRY.md`, + `dependencies.allowlist`. Brownfield: from the code. Greenfield: from the interview, gaps flagged `guessed`. +2. **Size the first milestone** (read `scope.md`) and draft its `MILESTONE.md` — goal · scope · exit criteria + · breadth-first tasks. +3. **Create the first task and draft its candidate specification bundle.** `new-task` is allowed pre-lock: + ```bash + python3 .add/tooling/add.py new-task <slug> --title "<first feature>" + ``` + Draft §1 (specify) · §2 (scenarios) · §3 (contract). **Leave §3 `Status: DRAFT`** — the lock is its + approval (see §5). You MAY `advance` through specify → scenarios → contract → tests pre-lock, but the + engine **refuses crossing into build** until you `lock` (`setup_unlocked`). Sequence: bundle → lock → build. +4. **Write `.add/SETUP-REVIEW.md`** per `setup-review.md`: every decision you drafted (foundation, scope, + first contract), **lowest-confidence-first**, each tagged `guessed` | `evidence-grounded`. + +## 4 · The one human gate — the baseline approval + +Open the report with the ARC (goal · done · plan) per `report-template.md`, then present +`SETUP-REVIEW.md` lowest-confidence-first (the `guessed` rows are what the human must actually check). They +confirm **once** — an explicit yes to the baseline approval itself, in conversation; ambient agreement mid-stream is +not a confirmation. On that recorded confirmation, you run the lock with their name: + +```bash +python3 .add/tooling/add.py lock --by "<name>" +``` + +Typing the command themselves stays the **escape hatch** — the decision is always the human's; you just +execute it. `lock` records the lock layers (foundation · scope · contract) in one atomic write and opens the +build. It is judgment-free — it does **not** parse `SETUP-REVIEW.md`; the human *reading* it is the review. + +## 5 · After the lock + +- The lock **is** the first task's contract approval — the v7 specification-bundle approval and the baseline approval collapse + into this single signature. Do **not** ask for a separate contract-freeze sign-off (that double-gates). +- Stamp the first task's §3 `Status: FROZEN @ v1` (lock-authorized), then read `phases/5-build.md` — build is + now open. Everything before this signature, you drafted. + +## Exit gate + +<exit_gate> +- [ ] `.add/state.json` exists; setup was seeded unlocked (`--await-lock`) then locked. +- [ ] Living docs filled (brownfield: from code, tagged evidence-grounded; greenfield: from the interview). +- [ ] First task created; §1–§3 drafted; `.add/SETUP-REVIEW.md` written lowest-confidence-first. +- [ ] Human confirmed the baseline approval and `add.py lock --by` ran with their name; first task §3 `FROZEN @ v1`; build open. +</exit_gate> + +## Next + +After the lock, read `phases/5-build.md` (build is open). · Book: `docs/10-setup-and-stages.md` +*(note: book chapters 10 / 13 / 14 still describe the older human-led setup until `book-align` lands).* diff --git a/.claude/skills/add/phases/1-specify.md b/.claude/skills/add/phases/1-specify.md new file mode 100644 index 000000000..c1120c6f8 --- /dev/null +++ b/.claude/skills/add/phases/1-specify.md @@ -0,0 +1,65 @@ +# Phase 1 — Specify (the rules) + +Goal: state what the feature MUST do and what it must REJECT, with zero ambiguity +for the AI to resolve by guessing. Fill **§1 SPECIFY** in TASK.md. + +Specify is **co-specification**: brainstorm the shape WITH the user, draft it, then let +the user validate with your advice. If you cannot write the spec, you do not yet +understand the feature — that is information, not an obstacle. Stop and ask. + +## Co-specify in three moves + +1. **Diverge** — before drafting, surface the decision space: the 2–3 genuine framings of the + feature + the open questions you would otherwise guess. Invite the user to add, kill, + redirect. (Conversational — no new file. At prototype/poc this shortens to one sentence.) +2. **Converge** — draft §1, then RANK where your confidence is lowest (below). +3. **Validate** — present the ranked uncertainty first; the user confirms, corrects, or sends back. + +## Produce (in TASK.md §1) + +<output_format> +- **Framings weighed** — a one-line trace of what you considered: `X (chosen) · Y · Z`. +- **Must** — each required behavior. +- **Reject** — each refused input/situation, paired with a **named error code** + (`amount <= 0 -> "amount_invalid"`, never "handle bad input"). +- **After** — the state that is true once it succeeds. +- **Assumptions — lowest-confidence first** — ranked most-likely-wrong → least. The top 1–2 carry a + `⚠` flag: `⚠ <assumption> — lowest confidence because <why>; if wrong: <cost>`. The rest are the + low-stakes `[x]` tail. Keep the ranking visible — a flat list of equal `[x]` ticks gets approved without reading. +</output_format> + +## The lowest-confidence flag is bundle-wide + +The single human approval happens once, at the contract freeze, over the whole bundle. So your +§1 ranking is the first input into a bundle-level flag the user reads at the decision point (`run.md`): +*"of everything I'm asking you to freeze, these 1–2 are most likely wrong."* A flag may point at +a §1 assumption, an uncovered scenario, or the contract shape. + +## AI prompt + +<prompt> +Role: a domain analyst who brainstorms, then asks rather than assumes. +Read first: CONVENTIONS · GLOSSARY · the user's raw input. +Objective: fill §1 SPECIFY with zero ambiguity left for the AI to resolve by guessing. +Steps: + 1. Surface 2–3 framings + the open questions; let the user react before you draft. + 2. Produce §1 — Framings weighed, every Must, every Reject with a named error code, the + After state, and the Assumptions RANKED lowest-confidence first. + 3. Flag the 1–2 where your confidence is lowest, each with why + cost. +Never: resolve an ambiguity by guessing. +</prompt> + +## Exit gate + +<exit_gate> +- [ ] Framings weighed noted; every required behavior stated. +- [ ] Every rejection has a named error code; success state-change described. +- [ ] Assumptions ordered lowest-confidence first; the 1–2 `⚠` flags carry why + cost — or an honest + "none material" that still names the single biggest risk (never a blank "none"). +</exit_gate> + +## Next + +`python3 .add/tooling/add.py advance` → read `phases/2-scenarios.md`. +Book: `docs/03-step-1-specify.md`. (UI feature? also sketch flows + every screen +state: loading/empty/error/success.) diff --git a/.claude/skills/add/phases/2-scenarios.md b/.claude/skills/add/phases/2-scenarios.md new file mode 100644 index 000000000..4f4093c42 --- /dev/null +++ b/.claude/skills/add/phases/2-scenarios.md @@ -0,0 +1,46 @@ +# Phase 2 — Scenarios (pass/fail cases) + +Goal: rewrite each rule as a concrete Given/When/Then that is readable by people +and checkable by machines. This is the highest-leverage artifact — the tests are +generated from it. Fill **§2 SCENARIOS** in TASK.md. + +## Produce (in TASK.md §2) + +<output_format> +```gherkin +Scenario: <short name> + Given <starting situation> + When <action> + Then <observable result> + And <what must remain unchanged> # REQUIRED for every rejection +``` + +The `And ... unchanged` clause catches corrupting partial failures (e.g. a balance +deducted before a check fails). Include it on every rejection. +</output_format> + +## AI prompt + +<prompt> +Role: a specification tester. +Read first: §1 · GLOSSARY. +Objective: one scenario per Must and per Reject rule, each result specific and observable. +Steps: + 1. Write one scenario per Must rule and one per Reject rule. + 2. For every rejection add an And-clause asserting what must NOT change. +Never: settle for a vague result ("then it works") — results must be specific and observable. +</prompt> + +## Exit gate + +<exit_gate> +- [ ] One scenario per Must rule. +- [ ] One scenario per Reject rule. +- [ ] Each result is a specific, observable fact. +- [ ] Every rejection asserts what stays unchanged. +</exit_gate> + +## Next + +`python3 .add/tooling/add.py advance` → read `phases/3-contract.md`. +Book: `docs/04-step-2-scenarios.md`. diff --git a/.claude/skills/add/phases/3-contract.md b/.claude/skills/add/phases/3-contract.md new file mode 100644 index 000000000..1a06f0298 --- /dev/null +++ b/.claude/skills/add/phases/3-contract.md @@ -0,0 +1,70 @@ +# Phase 3 — Contract (freeze the shape) + +Goal: fix the external shape — interfaces, data, names, error cases — and FREEZE +it. This is the decision point that makes the AI-led build safe: below it code is +disposable; above it nothing breaks because the shape does not move. Fill +**§3 CONTRACT** in TASK.md. + +## Produce (in TASK.md §3) + +<output_format> +- Interfaces (endpoints/functions/messages) with inputs/outputs. +- Request/response shapes + persistent schema (note transactional needs). +- Names drawn from `GLOSSARY.md` (same concept = same name everywhere). +- A response for **every** Reject error code from §1. + +Then mark `Status: FROZEN @ v1`. Generate a mock + contract tests so dependent +work can start before the real code exists. +</output_format> + +**The freeze is the one approval.** This decision point is where the single human approval lands, over the +whole bundle (§1–§4). Before asking for it, present the bundle **lowest-confidence first**: the 1–2 points +most likely wrong (`⚠ [spec|scenario|contract|test] … — because …; if wrong: …`) — aim the human's +eye before they freeze. Open that report with the ARC (goal · done · plan) per `report-template.md` so the +human sees the goal this freeze serves and the plan beyond it, not just the bundle. See `run.md`. + +## The freeze review checklist + +The human's one minute, aimed. Walk these six before saying yes: + +- **⚠ flags first** — read the lowest-confidence flags; accept each knowing its cost if wrong. + The engine refuses an unflagged freeze before build: a frozen §3 with no well-formed + lowest-confidence flag is rejected (`unflagged_freeze`), and `audit` re-checks it on every + record that crossed. +- **Intent** — does §1 say what you actually want built (and is anything you expected missing)? +- **Cases** — does every Must and Reject have an observable §2 scenario you care about? +- **Shape** — glossary names, error codes, additive vs breaking: is THIS the shape to freeze? +- **Risk** — is this scope high-risk or method-defining? Then require + `risk: high · autonomy: conservative` in the TASK.md header — the engine refuses an unguarded completion. +- **Tests** — will §4 go red for the right reason, asserting behavior rather than internals? + +This checklist AIMS the one approval — the freeze stays the only gate: no sign-off forms, no +extra documents. Reject any line and the bundle goes back to draft; that is +backward-correction, not failure. + +## AI prompt + +<prompt> +Role: an interface architect; frozen contracts are immutable. +Read first: §1 · §2 · GLOSSARY. +Objective: produce §3 — the frozen external shape, nothing more. +Steps: + 1. Define interfaces, shapes, and schema named from the glossary, with a response for every Reject code. + 2. Generate a mock returning the contracted shapes and contract tests pinning them. + 3. Mark FROZEN. No business logic. +Never: change a frozen contract — a change reopens Specify. +</prompt> + +## Exit gate + +<exit_gate> +- [ ] Versioned and marked `FROZEN`. +- [ ] Contract tests pass against the mock. +- [ ] Every name matches the glossary. +- [ ] Every spec rejection has a contracted response. +</exit_gate> + +## Next + +`python3 .add/tooling/add.py advance` → read `phases/4-tests.md`. +Book: `docs/05-step-3-contract.md`. diff --git a/.claude/skills/add/phases/4-tests.md b/.claude/skills/add/phases/4-tests.md new file mode 100644 index 000000000..9c7c04d88 --- /dev/null +++ b/.claude/skills/add/phases/4-tests.md @@ -0,0 +1,61 @@ +# Phase 4 — Tests (failing-first suite) + +Goal: turn scenarios + contract into automated tests and confirm they FAIL before +any code exists. This operationalizes red/green TDD: red now, green only after +Build. Fill **§4 TESTS** and write the suite into `.add/tasks/<slug>/tests/`. + +## The must-fail principle + +Run the suite now, with no implementation — it must be **red for the right +reason** (missing implementation, not a broken harness). A test that passes +before code exists is testing nothing and will wave bad code through later. + +## Produce + +<output_format> +- One executable test per scenario (§2), asserting **behavior, not internals**. +- Contract-conformance tests (shapes + error responses from §3). +- Side-effect assertions on rejection paths (`assert balance unchanged`). +- A recorded coverage target in §4. +</output_format> + +## Declaring where tests live + +§4's `Tests live in:` line is machine-read: when a task has no local `tests/`, +`add.py report` counts test functions at the declared path(s) instead. The FIRST +line matching `Tests live in:` is read; paths are its backticked tokens. +Resolution: `./…` → this task's dir · a token containing `/` → the project root +(the parent of `.add/`) · a bare name → a sibling of the previous token's +directory (else the task dir). A directory token counts the `*.py` files directly +inside it (non-recursive); a `.py` file token counts itself; anything else is +ignored. Resolved files are deduped, and reports mark declared counts with `†`. +Paths are confined: anything resolving (symlinks followed) +outside the project root counts 0 — `..` traversal, absolute paths, and +symlink escapes are never read. + +## AI prompt + +<prompt> +Role: a test author who writes tests before code. +Read first: §2 · §3. +Objective: a red suite that fails for the right reason — behavior, not internals. +Steps: + 1. Turn each scenario into an executable test. + 2. Add contract-conformance and edge-case tests. + 3. Run the suite and confirm it fails for the right reason; record a coverage target. +Never: implement the feature, or assert on internals. +</prompt> + +## Exit gate + +<exit_gate> +- [ ] One test per scenario. +- [ ] Suite runs and is **red for the right reason**. +- [ ] Tests assert observable behavior. +- [ ] Coverage target recorded. +</exit_gate> + +## Next + +`python3 .add/tooling/add.py advance` → read `phases/5-build.md`. +Book: `docs/06-step-4-tests.md`. diff --git a/.claude/skills/add/phases/5-build.md b/.claude/skills/add/phases/5-build.md new file mode 100644 index 000000000..7c4168e45 --- /dev/null +++ b/.claude/skills/add/phases/5-build.md @@ -0,0 +1,48 @@ +# Phase 5 — Build (AI writes the code) + +Goal: implement the feature so EVERY failing test passes — without changing any +test or the contract. This is the only phase the AI leads. It works because §1–§4 +removed all ambiguity. Write code into `.add/tasks/<slug>/src/`. + +## Work in small batches + +Pick ONE task-sized slice, restate the tests it must satisfy, implement, run +tests, iterate to green. Keep each batch small enough to review in full — you +cannot move faster than you can verify. + +## The cardinal rule + +**Never weaken or delete a test to make it pass, and never edit the frozen +contract.** That makes the code judge itself. A genuine need to change either is a +change request back to Specify. Honor the feature-specific safety rule named in §5 +(e.g. atomic balance update) — the one property tests alone may not force. + +## AI prompt + +<prompt> +Role: implement the feature so EVERY failing test passes — the build phase. +Read first: §1 · §3 · §4 · CONVENTIONS. +Objective: every §4 test green, one small batch at a time. +Steps: + 1. Make EVERY failing test pass, one small batch at a time, honoring the §5 safety rule. + 2. Report which tests pass and exactly what changed. +Never: change a test or the contract; use a package off the allow-list; or push past something unclear instead of asking. +</prompt> + +## Exit gate + +<exit_gate> +- [ ] All tests pass. +- [ ] Coverage did not decrease. +- [ ] No test and no contract modified by the AI. +- [ ] No dependency outside the allow-list. +- [ ] Change small enough to review in full. +</exit_gate> + +## Next + +`python3 .add/tooling/add.py advance` → read `phases/6-verify.md`. +Book: `docs/07-step-5-build.md`. + +> Under `autonomy: auto` (the default) Build and Verify run together as one dynamic, +> evidence-auto-gated run — not two manual stops. See `run.md`. diff --git a/.claude/skills/add/phases/6-verify.md b/.claude/skills/add/phases/6-verify.md new file mode 100644 index 000000000..fa52effb2 --- /dev/null +++ b/.claude/skills/add/phases/6-verify.md @@ -0,0 +1,73 @@ +# Phase 6 — Verify (evidence + non-functional review) + +Goal: establish trust and record an outcome. Passing tests are necessary, not +sufficient. Fill **§6** in TASK.md including the GATE RECORD. + +> **Who resolves this gate depends on the `autonomy:` header (see `run.md`).** +> Under `autonomy: auto` (the default) a run auto-PASSes once the evidence is +> complete — every test green, the convergence loops dry, and **no residue** +> (security · concurrency · architecture) — recording it as *auto-resolved* with +> the named run as accountable owner: an explicit PASS, not a skip. **Security is +> always a HARD-STOP and is never auto-passed.** Under `autonomy: conservative`, +> or whenever residue is found, this phase is **human-led** and the checks below +> are the human's. + +## Part one — confirm the evidence + +- [ ] All tests pass. +- [ ] Coverage did not decrease. +- [ ] No test or contract was altered during build. + +If any is false, stop and return to Build — there is nothing to verify yet. + +## Part two — check what tests miss + +- **Concurrency/timing** — is it correct when two run at once? (Tests run serially + and miss races.) This is usually the single most important check. +- **Security** — exposed secrets, injection openings, unexpected/invented + dependencies. A security finding is always `HARD-STOP`, never a waiver. + Writing ANY note on this line means the gate escalates to the human — and + start it with `NOTE` or `⚠` so `add.py audit` can see it: a marked security + note reviewed by the auto-gate is an audit finding (`unescalated_security_note`). +- **Architecture** — does it respect layering/dependency rules in CONVENTIONS.md? + +## Part three — the deep check (do not skim) + +Green tests prove behavior on the inputs you thought of. They do not prove the change +is *wired in*, nor that you did not leave a dead end behind — and for a non-coding change +they prove nothing about whether you actually *read* the thing you signed off. So one more +requirement, every gate: + +Deep check — do not skim. If the task produced code, record that every new symbol is +referenced (wiring) and that no new dead/unused code was introduced. If it produced prose +or non-code, record a semantic read — what you read in full and what it confirmed. Which +path applies is the resolver's judgement; the engine never classifies. + +Record it in the §6 **Deep checks** block — where each new symbol is called (a reference +search), the dead-code scan result, or the prose you read in full and what it confirmed. +An unfilled Deep checks block is a **shallow verify**, not a PASS. + +## Record exactly one outcome (no silent pass) + +When you present this gate to the human, open with the ARC (goal · done · plan) per +`report-template.md`, and reconcile its FLAGS with `add.py report --decide`'s open-item count +before the ask — per that file's reconcile rule (verify is where a flag-vs-digest mismatch bites). + +| Outcome | When | +|---------|------| +| `PASS` | all checks met | +| `RISK-ACCEPTED` | a **non-security** gap, with signed owner + ticket + expiry | +| `HARD-STOP` | any failing test or any security finding | + +## Exit gate / Next + +<exit_gate> +- [ ] Evidence confirmed, non-functional risks checked, outcome recorded — a person approved, or + (under `autonomy: auto` with no residue) the run auto-resolved as the accountable owner. +</exit_gate> + +```bash +python3 .add/tooling/add.py gate PASS # marks the task done +# or: add.py gate RISK-ACCEPTED | add.py gate HARD-STOP (return to Build) +``` +Then read `phases/7-observe.md`. Book: `docs/08-step-6-verify.md`. diff --git a/.claude/skills/add/phases/7-observe.md b/.claude/skills/add/phases/7-observe.md new file mode 100644 index 000000000..8fc33660d --- /dev/null +++ b/.claude/skills/add/phases/7-observe.md @@ -0,0 +1,40 @@ +# Phase 7 — Observe (feed the next loop) + +Goal: release deliberately, watch reality, and turn what you learn into the next +spec. Release is not the finish line — it is where the most reliable information +about the feature finally appears. Fill **§7** in TASK.md. + +## Do + +1. **Release behind a scope-of-impact limit** — feature flag and/or gradual rollout. +2. **Reuse scenarios as monitors** — the §2 scenarios that defined "correct" now + define what you alert on: overall error rate, each rejection's rate (a spike in + one is a signal), latency of the risky operation under load. +3. **Draft the next spec delta** — every defect, surprise, or new need becomes a + concrete change that re-enters the flow at Specify (a new task). + +## AI prompt + +<prompt> +Role: a reliability analyst feeding the next cycle. +Read first: telemetry · objectives · incidents. +Objective: turn what production shows into the next SPEC delta. +Steps: + 1. Report error-budget burn. + 2. Cluster errors and surface the top real-world failures. + 3. Draft a SPEC delta with evidence links. +Never: auto-roll-back — recommend; a human owns the production decision. +</prompt> + +## Exit gate + +<exit_gate> +- [ ] Released behind a flag/rollout. +- [ ] Scenario-based monitors live. +- [ ] A reviewed spec delta captured (becomes the next `new-task`). +</exit_gate> + +## Next + +Loop. The artifacts you built are living documents the next cycle refines. +Book: `docs/09-the-loop.md`. diff --git a/.claude/skills/add/report-template.md b/.claude/skills/add/report-template.md new file mode 100644 index 000000000..d8051fd85 --- /dev/null +++ b/.claude/skills/add/report-template.md @@ -0,0 +1,106 @@ +# Chat reports — the decision-point template (for the AI, not for add.py) + +The engine renders artifacts (`report`, `report --decide`, `status`); this file +governs the CHAT MESSAGE you wrap around them. The digest is the artifact BEHIND +your presentation, never a replacement for it — and your prose is never a +replacement for the digest. + +Use it every time you report at or near a decision point: an intake proposal, a +bundle approval, a verify gate, a task completion, a milestone close. + +## The decision arc — rendered first, above the five blocks + +Every report at a human gate opens with the **ARC** — three labelled lines that +place the decision in the work's whole arc, so the human confirms with sight of +where this is going, not just the step in front of them. Render it first, then a +separator, then the unchanged five blocks below: + +``` +ARC goal: <the milestone / project goal this decision serves> + done: <proven progress — tasks done · exit-criteria met · what this gate proves> + plan: <this gate → the next step → the goal> +``` + +- **goal** — the milestone or project goal the decision serves, read from the + `m-goal` line in `add.py status`; never re-typed from memory. +- **done** — proven progress only: exit-criteria met/total and tasks done from + the rollup, plus what this gate proves. An honest fact, never a hope. +- **plan** — this gate → the next step → the goal, mirroring the rollup's + `DECIDE NEXT` line. + +The arc is required at every human gate: **baseline-lock · contract-freeze · +verify · intake · scope · milestone-close · graduation**. The three labels stay +constant; their content adapts to the gate. The arc is presentation only — it +adds no gate and changes no PASS / RISK-ACCEPTED / HARD-STOP / freeze outcome. + +Its facts are engine-sourced, exactly like EVIDENCE below: goal = `m-goal` · +done = exit-criteria met/total + tasks done · plan = `DECIDE NEXT`. If your arc +and `add.py` output disagree, the engine wins — fix the arc, not the engine. + +### Per-gate examples — one shape, gate-specific content + +- **verify** — `goal:` ship the decision arc · `done:` report-arc tests 6/6 + green, gate ready · `plan:` PASS this gate → wire the arc into every gate → goal. +- **contract-freeze** — `goal:` … · `done:` bundle drafted, lowest-confidence + flag surfaced · `plan:` freeze §3 → build → goal. +- **milestone-close** — `goal:` … · `done:` exit-criteria 3/3 met, all tasks + done · `plan:` close → archive → the next milestone. +- **intake** — `goal:` the sized request · `done:` classified new-major, + rationale stated · `plan:` create the milestone → first contract → goal. + +## The five blocks, in order + +``` +SUMMARY one line: intent + target + where we are +DECISION what you need from the human (or "none — FYI") +⚠ FLAGS lowest-confidence first, why + cost-if-wrong +EVIDENCE small table: tests · gates · parity · check — engine-sourced +NEXT the single next action + what it unlocks +``` + +1. **SUMMARY** — one line carrying intent + target + position, e.g. + "v13 task 2/3 — tests-declared-fallback is green, gate PASS." The reader + knows where they are before they read anything else. +2. **DECISION** — the question the human must answer, stated plainly; exactly + one decision per report, or an explicit "none — FYI". If a decision exists, + ask it AFTER everything below has been shown (show-before-ask). +3. **⚠ FLAGS** — lowest-confidence first, each with *why* confidence is lowest and the + *cost if wrong*. Where TASK.md markers exist (`⚠` / `- [~]` / `- [ ]`), + quote them verbatim and keep their document order — extraction ≠ judgment. +4. **EVIDENCE** — engine-sourced facts pasted from `add.py` output, never + re-typed from memory. If your prose and the engine disagree, the engine + wins: fix the engine or the data, not the sentence. +5. **NEXT** — one action and what it unlocks. Mirror the rollup's DECIDE NEXT + line when it is right; overrule it only with a stated reason (e.g. planned + tasks the state file cannot see yet). + +**The ask itself** — when block 2's decision becomes a literal question component +(option picker, numbered menu), compose it as a summary: the detail stays in the +report above, the question carries intent + what "yes" means + the flag count. + +## Hard rules + +<constraints> +- **Summary-first.** Never bury the decision under a task list or a diff. +- **Show before ask.** Render the artifact (digest · diff · report) before any + approval question; the human decides on what they can see. +- **Reconcile the count.** Before the ask, your ⚠ FLAGS must reconcile with + `add.py report --decide`'s open-item count. If your prose calls an item + resolved while the digest still counts it open, the engine wins — fix the data + (the TASK.md markers the digest reads), not the sentence. A report whose flag + count disagrees with the engine is the un-transparent gate the ARC exists to close. +- **Never pre-stamp a human decision point.** Freeze / gate / lock fields stay DRAFT or + blank until the answer returns: show → ask → stamp → advance. An artifact + must never claim an approval that has not happened. +- **One report per decision point.** After an approval, point at the frozen artifact — + do not re-render the whole bundle. +- **Honest scope.** "Done" means the request, not the last task: report + "task 2/3", never "done" while approved scope remains. +- **The question is a summary, never the artifact.** Every approval ask carries + two layers: a compact SUMMARY · DECISION · ⚠ FLAGS block sits in chat + immediately before the ask (positional), and the question text itself is a + summary of two lines at most — intent + what "yes" means + the flag count — + pointing at the report above (compositional). The full bundle, diff, or + artifact lives only in the chat report; a question that re-carries it buries + the decision. +</constraints> diff --git a/.claude/skills/add/run.md b/.claude/skills/add/run.md new file mode 100644 index 000000000..36d603df5 --- /dev/null +++ b/.claude/skills/add/run.md @@ -0,0 +1,171 @@ +# The dynamic run — executing a locked scope + +Once a task's CONTRACT is frozen (phase 3), the scope is *locked*: the external shape will not move. +That lock is ADD's autonomy decision point — below it code is disposable; above it nothing breaks. This rubric +covers what runs on the far side of the decision point: the **build->verify half, executed as a dynamic, +self-improving run** instead of a manual, sequential build. The human-led **specification bundle** (Specify · Scenarios +· Contract) still owns *direction*, but v7 compresses it to a **single human approval at the decision point** +(see "The specification bundle" below) — the AI drafts the whole bundle, a human approves it once. + +> **Self-improving = within-run convergence + emit v5 deltas** — same definition as v5: tracked, +> evidence-backed, never autonomous training. The run converges in-turn AND feeds the human-gated +> consolidation loop (`deltas.md` · `fold.md`). The engine stays judgment-free: this is a rubric, not `add.py`. + +## The specification bundle (v7) + +The specification bundle used to be three separate approvals — Specify, then Scenarios, then the Contract +freeze. v7 compresses it to **one**. From the user's input the AI **drafts the whole specification bundle in one pass** — the Spec, the Scenarios, the Contract, and the failing Tests — and presents it together. The +human gives **one approval, at the frozen contract** (the decision point). That single approval is the green light +for the self-driving run. + +Why one approval and not zero: the contract freeze is the autonomy decision point, and the decision point **stays human**. +The AI *drafts* the contract but never *freezes its own* — a person approves the frozen shape before any +auto-run touches code. This is exactly what keeps "never self-gate a human-led gate" true under an auto +default: the one gate that remains is human. Drop it to zero and the AI would freeze the interface it +then builds against and self-gate the result — the circular trust v6's dogfood warned against. + +What the human is actually approving in that one gate: that the drafted Spec captures the real intent, +that the Scenarios cover the cases that matter, and that the Contract shape is the one to freeze. Reject +any part and the bundle goes back to draft — that is backward-correction (principle 4), not failure. +Approve, and the run begins. The decision-point guide (`phases/3-contract.md`) carries the +**freeze review checklist** — six lines that walk the human through exactly this, ⚠-first. + +**The lowest-confidence flag — aiming the one approval.** A single approval over a whole bundle is easy to +grant without reading. So the AI presents the bundle **lowest-confidence first**: of everything it is asking the human +to freeze, it names the **1–2 points most likely to be wrong**, tagged by part +(`⚠ [spec|scenario|contract|test] … — because …; if wrong: …`), each with *why* it is uncertain and +*what it costs if wrong*. The §1 assumptions feed it, but a flag may equally point at an uncovered +scenario or the contract shape. If nothing is materially uncertain, the AI still names the single +biggest risk, however small — never a blank "none". Honest about its limit: the flag records that the +human approved with the soft spots **in front of them**, eyes open; it makes a real review cheap and a +lazy one visibly negligent, but it cannot *force* engagement — and the AI never asserts that the human +engaged when it cannot know (a self-asserted gate would just move the unread approval one level up). Closing +that enforcement gap is the job of a CI checker, not of prose. + +## When the run begins — the scope-lock trigger + +The trigger is the **frozen contract**, nothing else. A run may start only when: + +- §3 CONTRACT is marked `FROZEN @ vN` (the shape is fixed), AND +- §4 TESTS exist and are RED for the right reason (the target the run drives to green). + +No frozen contract -> no run: you are still inside the specification bundle, and starting early is the +forward-skip the flow forbids. The lock is what makes autonomous execution *safe* — the AI cannot +drift the interface, because the interface is frozen above it. + +## The change scope — what the run may and may not touch + +<constraints> +A locked run has a hard boundary. It MAY: + +- write and rewrite **code** (`src/`) — code is disposable below the decision point; +- drive the **tests** to green WITHOUT weakening them (a weakened test is a method violation); +- gather **evidence** for the verify gate (test output, non-functional review). + +It MUST NOT: + +- change the **frozen contract** or the **locked scope** — a discovered gap is backward-correction: + the run STOPS and hands back to a human to reopen Specify (principle 4). The run never re-locks + scope on its own. +- weaken, delete, or skip a **test** to make the build pass (that inverts the method). +- touch the **specification-bundle artifacts** (§1–§3) except to halt and escalate. +</constraints> + +Crossing the boundary is not a fast run; it is an unverified one. When the run hits something only the +specification bundle can resolve, it stops — and that stop is the loop working, not failing. + +## The dynamic run — fan-out and in-run convergence + +Once it starts, the run does not crawl the build in one linear pass. It **fans out** the independent +work — several build attempts, several test-fix loops, several checks at once — and then **converges** +on a trustworthy result with three loops: + +- **loop-until-dry** — keep hunting failures and gaps until N consecutive passes find nothing new. + Stopping at the first green is how defects survive; the run stops only when the well runs dry. +- **adversarial verify** — for every "done" claim, an independent skeptic tries to REFUTE it. The + claim survives only if it withstands refutation, not because one pass looked plausible. +- **completeness-critic** — a final pass that asks "what did we NOT cover — a scenario, a non-functional risk, + an unstated assumption?" Whatever it finds re-enters the run. + +The run ends only when the loops go dry AND the auto-gate's evidence is satisfied. This is the run +**self-improving within the turn** — the same convergence the foundation loop runs across milestones, +compressed into one task. + +## The automated quality gate + +<constraints> +The verify gate may be resolved by **evidence** rather than by a person — when the evidence is +sufficient and the result is recorded (principle 7, reframed: an automated, recorded pass is an +explicit pass, not a skip). + +- **Auto-PASS requires ALL of:** every test green; coverage not decreased; no test weakened and no + contract edited; the convergence loops dry; the completeness-critic found nothing open; and the + deep check below recorded. +- **The deep check (every gate, no skim).** Deep check — do not skim. If the task produced code, record + that every new symbol is referenced (wiring) and that no new dead/unused code was introduced. If it + produced prose or non-code, record a semantic read — what you read in full and what it confirmed. + Which path applies is the resolver's judgement; the engine never classifies. An unfilled deep check is + a **shallow verify**, not an auto-PASS — evidence the work is wired, not merely plausible. +- **Always escalates to a human (never auto-passed):** any **security** finding (HARD-STOP, always); + a **concurrency**/timing risk the tests cannot exercise; an **architecture**/layering violation; and + any failing test. These are the residue principle 2 names — automation cannot judge them. +- **Records exactly one outcome** (no silent skip): `PASS` (evidence + the named run as accountable + owner) · `RISK-ACCEPTED` (non-security, signed) · `HARD-STOP`. The record states it was + auto-resolved, names the run, and lists the residue checks performed. + +The auto-gate NEVER writes a human signature it did not get. An auto-PASS is logged as *auto-resolved*, +honestly — the line between a pass and a skip is the recorded outcome, not a forged name. +</constraints> + +## Emitting deltas — feeding the foundation back + +The completeness-critic does not discard what it finds. Every gap, surprise, or convention that helped +or hurt becomes an **`open` lesson learned** in the task's OBSERVE block, in the `deltas.md` grammar, +tagged by competency: + +- a finding the run FIXED but that taught the foundation something (a missing scenario -> `TDD`); +- a finding the run could NOT fix — a residue escalation -> a delta AND the escalation to a human. + +These `open` deltas feed v5's human-gated consolidation (`fold.md`) at milestone close: the run emits `open`; +the human consolidates. That is the loop closing — **v6 run -> v5 foundation** — so a dynamic run sharpens the +five competencies instead of letting its findings evaporate at end-of-run. + +## The autonomy level + +<constraints> +How much a run may auto-gate is a **per-scope setting**, not a global switch (principle 5: trust is +earned per scope). A task declares its level in its `TASK.md` header: + +``` +autonomy: auto | conservative +``` + +- **auto (the default)** — the run may auto-PASS when the evidence + residue checks above are + satisfied. Security still always escalates. This is the default starting point: a frozen contract + flips the task into a self-driving run that converges and auto-gates on evidence. +- **conservative** — the deliberate *lowering*: the run does all the work and converges, but STOPS at + the verify gate for a human. Auto-PASS is disabled. Choose it wherever evidence is thin or risk is high. + +> **v7 reversal (recorded, not hidden).** Earlier the default was `conservative` and `auto` was the +> earned exception; v7 flips this — `auto` is the default, `conservative` is the deliberate lowering. +> What did **not** change is principle 5: the autonomy level is still **per-scope**, and it still lives in the +> `TASK.md` header, and you still lower it anywhere risk demands. Only the starting point moved. + +**The high-risk guard — `auto` is refused where it matters most.** The autonomy level is not a blank cheque. On a +**high-risk or method-defining scope** — anything where a wrong-but-plausible result is expensive or +hard to reverse (auth, money, data-loss paths, the method/trust-layer itself) — `auto` must be lowered +to `conservative`; leaving it at `auto` there is the reject code **`unguarded_high_risk_auto`**. This +closes the v6 dogfood gap, where the whole milestone ran at `auto` on the riskiest possible +scope (defining the method) with no friction. The default is `auto` *for ordinary, well-tested scope*; +high risk still earns a human gate. + +Judging *what* is high-risk stays human — the scope declares **`risk: high`** in the same `TASK.md` +header where the autonomy level lives, reviewed at the freeze like every header line (the engine never +classifies scope). **Since v14 the guard is mechanical for the declared case:** +the engine refuses the declared combination — `add.py gate` will not complete (`PASS`/`RISK-ACCEPTED`) a task whose header +carries `risk: high` without `autonomy: conservative` (error `unguarded_high_risk_auto`; `HARD-STOP` +always records — stopping is never blocked), and `add.py audit` flags the same code on a finished +record whose header was tampered or whose GATE RECORD reviewer is the auto-gate — which CI enforces +(audit-ci). The honest limit mirrors the audit's: an **undeclared** high-risk scope passes; declaring +is the human decision point, the engine enforces what was declared. +</constraints> diff --git a/.claude/skills/add/scope.md b/.claude/skills/add/scope.md new file mode 100644 index 000000000..432634182 --- /dev/null +++ b/.claude/skills/add/scope.md @@ -0,0 +1,80 @@ +# Scope drafting — turn a classified request into a versioned MILESTONE.md + +This is the **second half of intake**. `intake.md` CLASSIFIES a request into a bucket; scope +drafting turns that classified request into a confirmed, well-formed, versioned `MILESTONE.md` +through discussion. The MILESTONE.md template is the SHAPE; this rubric is HOW to fill it well. +You (the AI) **propose**; the human **confirms before anything is created**. + +## What to do per intake outcome + +scope drafting honors intake's classification — it never re-sizes a request: + +| intake outcome | scope-loop action | creates (after confirm) | +|----------------|-------------------|-------------------------| +| `new-major` / `sub-milestone` | draft ONE MILESTONE.md (fill the template via discussion) | 1 milestone | +| `task` | route to `add.py new-task <slug>` (it fits the active milestone) | 0 milestones | +| `change-request` | route to SPECIFY/CONTRACT of the affected task | 0 milestones | +| `split_required` | draft ALL N items as a batch in ONE pass | N milestones/tasks | + +**Confirm before create is the invariant.** It holds in the one-pass split case too: "one pass" +means one drafting pass, NOT auto-creation. Nothing is written to disk — single draft or the +whole batch — until the human confirms. You propose; you wait. + +## Brainstorm before you draft — co-specify at milestone level + +Don't draft a MILESTONE.md from thin input. Run the same three-move co-specify as a +task's §1 (`phases/1-specify.md`) — Diverge (framings + open questions) → Converge +(draft + rank) → Validate (show flags first) — raised to milestone scope. Ask only +what moves the goal, the In/Out line, or the task list; skip what PROJECT.md settles. +Draft the WHOLE milestone before showing; nothing hits disk until the human confirms. + +Diverge seeds (pick the live ones): +- **Outcome** — done means a user can do *what* they can't today? (goal sentence) +- **Edge of scope** — nearest thing assumed IN that you want OUT? (Out list) +- **Riskiest decision point** — which contract, if wrong, costs the most rework? (freeze-first) +- **Done-looks-like** — how do we SEE each outcome without reading code? (exit criteria) +- **First slice** — which task unblocks the rest? (breadth-first order) + +Rank assumptions lowest-confidence first; the top 1–2 get the flag the human reads at confirm: +`⚠ <assumption> — lowest confidence because <why>; if wrong: <cost>`. Present the draft via +`report-template.md` — open with the ARC (goal · done · plan): the goal this milestone serves, +what is already covered, and the plan its task list lays out. + +## Drafting a good MILESTONE.md (section by section) + +- **goal** — ONE sentence, an outcome not an output ("a user can size any request", not "write + intake.md"). If it needs an "and", it is probably two milestones. +- **Scope In/Out** — the explicit anti-creep deferral list. Naming what is OUT is as important + as what is IN; an empty Out list usually means the scope is not yet thought through. +- **Shared decisions & glossary deltas** — cross-cutting rules every task must honor, named from + the glossary. New terms get a glossary entry (the living documentation stays honest). +- **Shared / risky contracts to freeze first** — the decision points between tasks; name the owning task. +- **Tasks (breadth-first)** — `slug · depends-on · one line` each. Decompose by deliverable, not + by phase; keep each task one-file-sized. Order by dependency, not by guesswork. +- **Exit criteria** — observable, and **every exit criterion maps to a declared task slug** + (no dangling criterion). Each line answers "which task delivers this, and how would we see it?" + +## Reject codes (emit `{ reject, rationale }`, create nothing) + +<reject_codes> +- `not_classified` — the request has not been through intake yet. Classify it first; you cannot + draft scope for an unclassified request. +- `dangling_criterion` — a drafted MILESTONE.md has an exit criterion that maps to no declared + task slug. FIX the draft (add the task or drop the criterion) before proposing — never propose + a malformed milestone. With no engine lint, you are the first check and the human is the backstop. +- `no_milestone` — intake routed the request to `task` or `change-request`; scope drafting + creates NO milestone. Honor the classification; do not invent milestone-sized scope. +</reject_codes> + +## Worked example (from this repo's own history) + +Request: *"open the Interface & Intake milestone"* → intake classified it `sub-milestone` of the +live v4 self-driving theme → scope drafting produced **`.add/milestones/v4-1/MILESTONE.md`**: + +- **goal**: make ADD harness-drivable and self-scoping — machine-readable state plus an + AI-facilitated request→versioned-milestone intake loop (the real v4-1 goal, one outcome sentence). +- **tasks** (breadth-first): `machine-state-json` · `versioning-policy` · `scope-loop`. +- **exit criteria** — each maps to its task slug: `--json` emits owner+stop (← machine-state-json), + the AI proposes a bucket with rationale (← versioning-policy), the AI drafts a versioned + MILESTONE.md via discussion (← scope-loop). Every criterion names the task that delivers it — + which is exactly the well-formedness rule above, checkable against the real file. diff --git a/.claude/skills/add/setup-review.md b/.claude/skills/add/setup-review.md new file mode 100644 index 000000000..847ad52ac --- /dev/null +++ b/.claude/skills/add/setup-review.md @@ -0,0 +1,65 @@ +# Setup review — the one page the human signs + +Autonomous setup ends at a single human gate: the **baseline approval** (`add.py lock`). Before that +signature is honest, the human needs to see *what you drafted and how sure you were* — not re-derive +it. `SETUP-REVIEW.md` is that page: every decision you made while drafting the foundation, first-scope, +and the first contract, **ordered lowest-confidence-first** so the riskiest guesses meet their eye first. + +This is the setup-level analog of presenting a task's specification bundle lowest-confidence-first at the contract freeze. +The engine never reads this file — `add.py lock` is judgment-free, the signature *is* the gate (see +`setup-lock-state`). The human **reading** this page is the review; your job is to make the reading honest. + +## Where it lives + +Write **one** artifact at `.add/SETUP-REVIEW.md`. **Never clobber a human-edited one** — if it already +exists with hand edits, append/update, don't overwrite (the same non-clobber rule `init` applies to +living docs). It is a per-onboarding, setup-level artifact; it sits beside `PROJECT.md`, not under a task. + +## The template + +```markdown +# SETUP REVIEW — <project> + +<stage> · <brownfield | greenfield> · drafted by <model> @ <date> + +| # | Decision | Lands in | Tag | Why / Evidence | +|---|----------|----------|-----|----------------| +| 1 | <the drafted decision> | PROJECT.md \| scope \| first-contract | `guessed` | <the inference + why you had to guess> | +| 2 | <…> | <…> | `evidence-grounded` | <cite the source file/line you read it from> | + +Sign: confirm in chat → the agent runs `add.py lock --by "<name>"` (typing it yourself works too) +``` + +Rows are numbered for reference at the gate ("row 1 is where my confidence is lowest"). + +## The two rules that make it honest + +<constraints> +1. **Lowest-confidence-first.** Order rows by confidence **ascending**. A `guessed` row always floats above an + `evidence-grounded` one. The point is not completeness theatre — it is to spend the human's attention + where it changes outcomes: the top of the table is the part they actually need to challenge. + +2. **Every row is tagged — `guessed` or `evidence-grounded`.** + - `evidence-grounded` — you read it from the code/repo. **Cite the file** (e.g. `pyproject.toml`, + `src/orders/models.py`). Brownfield onboarding (see `adopt.md`) is mostly these. + - `guessed` — the repo was silent, so you inferred it. **State the inference and why.** Thin-greenfield + onboarding (a near-empty repo, only the 4-lens answers) produces these. These are what the human + must check; that is why they sit on top. + + The tag vocabulary is shared with `adopt.md` — the brownfield map tags each filled living-doc decision + `guessed`/`evidence-grounded`, and those tags flow straight into this table. +</constraints> + +## Where it ends + +`SETUP-REVIEW.md` is **read-only context** for the baseline approval. You do not ask the human to approve it +field-by-field; you present it, lowest-confidence-first; they confirm in conversation, and you run the lock +with their name: + +```bash +python3 .add/tooling/add.py lock --by "<name>" +``` + +`lock` records the lock layers and opens the build — it does **not** parse or validate this file (the +engine stays judgment-free). The review lives in the human's reading of the page, not in the tool. Make +the top of the table the truth they most need, and the one signature is informed. diff --git a/.claude/skills/add/streams.md b/.claude/skills/add/streams.md new file mode 100644 index 000000000..8cfc6146c --- /dev/null +++ b/.claude/skills/add/streams.md @@ -0,0 +1,256 @@ +# Parallel streams — pipelining independent tasks + +Load this **only** when a milestone has more than one task and you want to run them +concurrently. The default ADD path is one task at a time; this rubric is the opt-in +escape hatch for when independent tasks are queued and a human is ready to review. + +It changes **no `add.py` code and no phase semantics**. It is a way *you, the +orchestrator*, drive several tasks at once by reading the dependency DAG that +`add.py status` already prints, and spawning one worker per ready task. + +## The honest frame — this is pipelining, not N× speed + +With **one human reviewer** you cannot beat `review_time × N_tasks` (the human-led +decision points are serial — `docs/10-setup-and-stages.md:91`). So the win is **not throughput**: +it is that the reviewer is **never blocked waiting on a build**. While the human reviews +task A's frozen bundle, the builds for B·C·D run behind *their* frozen contracts. You hide +build latency under human latency. Do not promise more than that. + +## The two queues + +Compute both from one `python3 .add/tooling/add.py status` — no new state: + +- **READY-QUEUE** — tasks in the active milestone where `phase ≠ done` **and** every + `deps=` task already shows `gate=PASS`. These are the only tasks a worker may pick up. + A task with unmet deps stays queued; a task finishing PASS unblocks its dependents on + the next `status`. +- **REVIEW-QUEUE** — the irreducibly serial part: the **bundle approval** (contract + freeze) and any **Verify escalation**. One human, one queue. Present these one at a + time, never in a batch the human will approve without reading. + +``` + add.py status ─► READY-QUEUE ──spawn workers──► builds run ──► REVIEW-QUEUE ──► done + (deps=PASS?) (machine span) (concurrent) (decision points, + ▲ strictly serial) + └──────────────── a task gating PASS unblocks its dependents ──────────────┘ +``` + +## The autonomy level is the throttle (not a new flag) + +How much concurrency you actually get is set by each task's `autonomy:` header +(`run.md`), not by this rubric: + +| `autonomy` (TASK.md) | What serializes on the human | Concurrency | +|----------------------|------------------------------|-------------| +| `conservative` | bundle approval **+** every Verify | pure pipelining — builds overlap, both gates queue | +| `auto` (default) | bundle approval **only**; Verify auto-PASSes on evidence | real concurrency — only the decision point + residue escalations queue | +| `auto` but **high-risk** | refused → forced `conservative` (`unguarded_high_risk_auto`) | back to pipelining, by design | + +The irreducible floor is **one human approval per task at the contract decision point** — the decision point +never drops to zero (`run.md:22`). That floor is correct; do not engineer around it. + +## Who writes what — the hard boundary + +<constraints> +- **You (orchestrator)** own all shared writes: `MILESTONE.md`, and every + `add.py advance <slug>` / `add.py gate <outcome> <slug>` call. **Always pass the explicit + `<slug>`** — `advance`/`gate`/`phase` all take an optional task slug and act on it + (`add.py` `_resolve_task`); omitting it falls back to the single `active_task`, which + races once more than one stream is live. Name the task every time. Workers never run these. +- **A worker** owns only its own `.add/tasks/<slug>/` — it builds `src/`, drives the + tests green, gathers evidence, and writes `SUMMARY.md` + OBSERVE deltas. It touches + **no sibling stream and no shared file**. +- **Isolation**: spawn each worker with `isolation="worktree"` so concurrent builds + cannot collide. The worktree is discarded on failure; the task resets to its last-good + phase. +</constraints> + +## Design for failure (required) + +- **Fresh worktree base (verify base == HEAD)** — create each worker's worktree from current + `HEAD` **after** you commit the task's frozen specification bundle (spec · scenarios · contract · tests). A + worktree forked from a stale base forces the worker to recreate the frozen artifacts by hand + (the v10 dogfood hit exactly this). Before the worker starts, confirm `git -C <worktree> + rev-parse HEAD` equals the orchestrator's `HEAD`; if it drifted, `git merge` the base in first. +- **Lease + timeout** — record which worker holds which task (in the wave ledger, below); + if a worker dies, release the claim back to READY (re-spawn, do not assume partial work is sound). +- **Failure isolates** — a worker that hits a STOP-and-escalate (below) blocks only its + own task. Siblings keep running; the escalation joins the REVIEW-QUEUE. +- **Circuit-breaker** — if N workers fail in a wave, stop fanning out and fall back to + sequential. Repeated failure means the scope was wrong, not the parallelism. + +## Wave ledger — the wave's resume point + +A single task resumes from `state.json`; a wave used to resume from nothing — the +task ↔ lease ↔ fork-base ↔ autonomy ↔ merge-order mapping lived only in the orchestrator's +chat context, and the v12-1 recurrence proved that discipline without an artifact fails +(the base check existed in prose and never ran). The ledger fixes both: it is the file you +re-orient from, and its evidence cells cannot be filled without executing the checks. + +**The file** — `.add/milestones/<m>/WAVE.md`, orchestrator-owned like `MILESTONE.md` and +`state.json`. ONE live wave per milestone at a time; opening a second while one is live is +refused (`wave_already_live`). **Workers never read WAVE.md** — the orchestrator copies the +relevant mid-wave decisions into each worker's PROMPT.md at spawn/respawn, so the worker +contract below stays unchanged and no worker widens into sibling state. + +```markdown +# WAVE.md — transient wave ledger (orchestrator-owned · one live wave per milestone) +wave: <n> · opened: <date> · status: live|merging +base: <orchestrator HEAD at spawn — the sha every fork must equal> + +### Roster (lease ledger) +| task | lease (worker) | fork-base (pasted) | autonomy | spawned | timeout | +|--------|----------------|---------------------------------------------|----------|---------|---------| +| <slug> | wt-a | <paste `git -C <wt> rev-parse HEAD` output> | auto | <time> | <dur> | + +### Mid-wave decisions +- <date> <decision a later or respawned worker must honor — copy it into that worker's PROMPT.md> + +### Merge order (serial; integration Verify per merge) +1. <slug> → 2. <slug> +``` + +**Evidence cells, not ticks.** The fork-base cell holds the PASTED output of +`git -C <worktree> rev-parse HEAD`, and it must equal `base:`. A tick is not evidence; a row +you can only fill by running the command is the fresh-worktree-base check EXECUTING — the +v12-1 lesson (words-exist ≠ method-works) closed structurally. Spawning a worker whose roster +row lacks that evidence is refused (`unverified_fork_base`). + +**Lifecycle — open → consume → digest → delete.** Open the ledger when the first worker +spawns. The serial integration Verify consumes it (the merge order is read from it, one +worktree at a time). At wave close, absorb the evidence digest — wave base · roster→fork-base +evidence · merge order · integration-Verify outcome — into `MILESTONE.md` as an append-only +`## Wave log` block (this is the integration-Verify *record*, previously homeless), and only +then remove the file. Removing WAVE.md before the digest is absorbed is refused +(`digest_not_absorbed`) — the proof the checks ran must outlive the file. + +**Resume rule.** On session start, a live WAVE.md is the wave's resume point: re-orient from +the file — roster, bases, decisions, merge order — never from conversational memory. + +## Merge is serial — integration Verify + +Parallel build, **serial integration**. After workers return, you merge the worktrees +one at a time and run the **integration** Verify — the concurrency / architecture / layering +checks that `run.md:102` says automation cannot judge. Two green tasks in isolation can +still conflict when merged; this step is where that surfaces. Never auto-pass it. + +Each worktree carries a full copy of `.add/`. Merge back **only** `src/`, `tests/`, and the +worker's own `.add/tasks/<slug>/` (TASK.md · SUMMARY.md) — `.add/state.json`, `MILESTONE.md`, +and the live `WAVE.md` stay orchestrator-owned, or a parallel merge will drag stale state back. + +## The worker contract — portable across coding agents + +A worker **is** the dynamic run (`run.md`) for one task. Keep two things separate: + +- **The contract** (below) — the prompt. It is **agent-agnostic**: it names no vendor tool, + no model, no spawn API. It is a durable ADD artifact, like the spec and the tests. +- **The adapter** (next sections) — the thin, swappable mapping that tells *one* runner + (Claude Code · Codex · opencode · pi-mono · any CLI agent) how to launch the contract. + +This split is the whole point: the same frozen contract runs on any agent; only the adapter +changes. Fill every `{{...}}` per stream. The ADD-specific value is `<touch_boundary>` + the +"return a verdict, never write shared state" rule — they are identical on every runner. + +```xml +<!-- PROMPT.md — dropped into the worker's worktree, or passed inline. No runner-specific tokens. --> +<objective> +Execute the LOCKED dynamic run for task '{{TASK_SLUG}}' in milestone {{MILESTONE}}: +drive §4 TESTS red→green against the FROZEN contract {{CONTRACT_VERSION}}, converge, and +resolve verify per autonomy={{AUTONOMY}}. You own ONLY the machine-led span — the two human +decision points (bundle approval · escalated Verify) are NOT yours. +</objective> + +<persona> +You are a {{DOMAIN}} engineer with 15 years building {{DOMAIN_DETAIL}}. +A wrong-but-plausible result here is expensive; correctness over speed. +Work step by step: +1. Load the context files. Confirm the start gate: §3 CONTRACT FROZEN @ {{CONTRACT_VERSION}} + AND §4 TESTS RED for the right reason. If not → STOP and escalate (forward-skip forbidden). +2. Build in small batches in src/ until the red tests pass — never weaken or skip a test. +3. Converge: loop-until-dry · adversarial-verify every 'done' claim · completeness-critic. +4. Resolve verify per the boundary. Write SUMMARY.md + OBSERVE deltas (deltas.md grammar). +Score confidence (0-1) on Completeness · Clarity · Practicality · Optimization · EdgeCases · +Self-Eval; if any < 0.9, refine before returning. +</persona> + +<touch_boundary> <!-- from run.md:56-73; the worker's contract, identical on every runner --> +MAY: rewrite code in src/ · drive tests green WITHOUT weakening them · gather verify evidence. +MUST NOT: edit the frozen CONTRACT or locked scope · weaken/delete/skip any test · + touch §1–§3 bundle artifacts · write MILESTONE.md / state.json / any sibling stream. +STOP-and-escalate (return your findings; do not decide): + • a discovered scope/contract gap → backward-correction, reopen Specify (principle 4) + • any SECURITY finding → HARD-STOP, always + • a concurrency/timing OR architecture/layering risk the tests cannot exercise + • [include this bullet ONLY when autonomy=conservative] the verify gate itself — STOP for the human +Auto-PASS only if autonomy=auto AND: all tests green · coverage not decreased · no test weakened · + no contract edited · loops dry · completeness-critic clean · no residue above. Log it as + auto-resolved, naming this run as owner — never forge a human signature. +</touch_boundary> + +<context_files> <!-- paths relative to the worktree root --> +.add/PROJECT.md · .add/milestones/{{MILESTONE}}/MILESTONE.md (READ-ONLY) · +.add/tasks/{{TASK_SLUG}}/TASK.md · .claude/skills/add/run.md · .claude/skills/add/deltas.md +</context_files> + +<expertise> +Adopt the persona above. If your runner supports specialist injection — a Claude Code skill, +a Codex/opencode system-prompt preamble, an agent profile — load the one matching {{DOMAIN}}. +If it does not, the persona IS your expertise. +</expertise> + +<tools> +Navigate with your runner's code-intelligence: mcp__serena under Claude Code; LSP / ctags / +ripgrep otherwise. Design every IO path for failure — timeouts, retries, rollback. +</tools> + +<return> <!-- the worker PROPOSES; the orchestrator RECORDS. A worker never runs add.py. --> +End with a structured verdict AND write the same into SUMMARY.md in the task dir: +{ task, outcome: PASS|RISK-ACCEPTED|HARD-STOP|ESCALATE, evidence: <tests+coverage>, + residue: [security|concurrency|architecture findings], deltas: [open lessons learned] }. +Do NOT touch add.py or any shared file — the orchestrator gates on your verdict. +</return> +``` + +## Choosing the model — vendor-neutral tiers + +ADD picks a **tier** from the scope's nature; the adapter maps the tier to the runner's model id. +The contract is identical whichever model runs it (the model is disposable, like the code): + +| Tier | When | Claude Code | Any other runner | +|------|------|-------------|------------------| +| **mid** | ordinary, well-tested scope; clear contract | `sonnet` | the runner's balanced model | +| **top** | complex / ambiguous / cross-cutting / broad scope of impact | `opus` | the runner's strongest reasoning model | + +Two rules sit **above** model choice and never bend: +- **High-risk ⇒ `conservative` autonomy, regardless of model** (`run.md` high-risk guard). A + stronger model does not buy back the human gate. +- **Security residue always escalates** — no tier and no model auto-passes it. + +## The spawn adapter — one thin mapping per runner + +ADD needs six capabilities from any runner. **Isolation is the one ADD owns itself** (a git +worktree), so streams stay portable even on a runner with no native sandbox — ADD makes the +worktree, then points the agent at that directory. + +| ADD needs | Abstract | Claude Code (verified reference) | Any CLI agent — Codex · opencode · pi-mono · … | +|-----------|----------|----------------------------------|-----------------------------------------------| +| spawn a worker | prompt + label | `Task(description=…, prompt=…)` | `cd $WT && <agent> run --prompt-file PROMPT.md` | +| pick the model | tier → id | `model="opus"\|"sonnet"` | a `--model <id>` flag | +| isolate | worktree | `isolation="worktree"` | `git worktree add $WT HEAD` (after committing the bundle; verify base == HEAD), then run inside it | +| load context | files / cwd | `<context_files>` + repo cwd | run inside `$WT`; paths are relative | +| domain expertise | skill / preamble | a Claude skill in `<expertise>` | a system-prompt / profile preamble | +| return a verdict | structured | final message (optionally a schema) | stdout JSON the orchestrator parses | + +The **hint of `Task` spawn** is the Claude Code column — the worked reference. For any other +agent the recipe is the same shape: `git worktree add` → point the agent CLI at that dir with +the chosen model → it reads `PROMPT.md` → you parse its verdict. + +> **Honesty:** only the Claude Code column is verified. The CLI forms for Codex/opencode/pi-mono +> are *illustrative shapes*, not confirmed flags — exact syntax differs per runner and version; +> confirm with the `find-docs` skill. The portable, durable parts are the **contract** and the +> **six-capability mapping**, never any one runner's flags. + +When workers return, **you** record each outcome with the explicit slug — `add.py advance <slug>` +as evidence lands, `add.py gate PASS|RISK-ACCEPTED|HARD-STOP <slug>` at verify — then re-read +`status` to refill the READY-QUEUE. The worker proposes a verdict; the orchestrator records it. +That split is exactly what lets a non-Claude worker take part without ever touching shared state. diff --git a/.gitignore b/.gitignore index b3abbbce5..1513f43db 100644 --- a/.gitignore +++ b/.gitignore @@ -95,4 +95,7 @@ __pycache__/ tmp/ # MkDocs documentation site build output -/site/ \ No newline at end of file +/site/ + +# Linux VM cross-build dir (avoids Mach-O/ELF clobbering in shared target/) +/target-linux/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..f58bc3430 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside --> +## ADD — how to work in this repo + +This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build; +the human owns direction and verification. The loop below works for any agent — +Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code: + +1. Run `python3 .add/tooling/add.py status` — where the project is and what's + next (the resume point; read it first every session). +2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task + builds on. +3. Run `python3 .add/tooling/add.py guide` — it names the phase and the exact + phase-guide file to read (the `guide :` line). Work ONLY that phase — each + guide ends with its exit gate and the command to move on. + +The flow: INTAKE sizes a request into a milestone; each task runs the +**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle, +ONE human approval at the frozen contract — then a self-driving build→verify +run. Non-negotiable for every agent: +Never weaken a test or edit a frozen contract to make a build pass; a security +finding is always HARD-STOP — never auto-passed. + +On Claude Code the `add` skill drives this loop automatically; other agents +follow the three steps. The book is in `.add/docs/`. This block is generated +by `add.py sync-guidelines`; edit outside the markers, not inside. +<!-- ADD:END --> diff --git a/CHANGELOG.md b/CHANGELOG.md index df5c0da5f..8ca1b34d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed — Event-driven cross-shard wake (the ~1ms monoio floor is gone) + +- **The monoio shard event loop is now event-driven.** Its single await point + was the 1ms periodic tick, so every cross-shard hop queued up to 1ms on the + target shard plus up to 1ms on the origin's reply sweep. The loop now races + the tick against the shard's SPSC `Notify` (hand-rolled allocation-free + `race2` future — `monoio::select!` remains banned), and connection tasks + await cross-shard replies directly. Cadence work (WAL flush, cached clock, + snapshot/auto-save sub-timers) stays pinned to the timer arm. Measured on + a 4-shard Linux server: single-client cross-shard SET p99 4.07 ms → 0.071 ms + (57×), pipelined SET +368%, non-pipelined SET +404%; single-shard + throughput +63–80%. (PR #172) +- **Drain-cap self-re-notify:** a >256-message cross-shard burst no longer + strands its tail until the next tick — a capped drain re-arms the wake + immediately (both runtimes). +- **New INFO Stats counters:** `spsc_notify_wakes` and `spsc_drain_renotify` + make the event-driven path observable from a black-box client. + +### Changed — Hot-path lock quick wins + +- Per-command global lock acquisitions removed from the command dispatch path + (replication offset reads, shared-databases lookups, socket-option setup, + accept-path state), with the replication backlog append switched to a + bulk-copy. (PR #172) + +### Fixed + +- `uring_handler` in-flight send queue used `Vec` API on a `VecDeque` + (`.push` → `.push_back`) — the Linux+tokio io_uring bridge path failed to + compile on its own. (PR #172) + ## [0.3.0] — 2026-06-11 Hot-shard elasticity + vector engine maturity: background HNSW compaction diff --git a/CLAUDE.md b/CLAUDE.md index 9b101b263..06d163e8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,28 +28,30 @@ OrbStack is used for Linux-parity builds, production benchmarks, and io_uring te - **Rust:** 1.94.1 (MSRV-pinned) - **Tools:** build-essential, pkg-config, libssl-dev, redis-server -OrbStack auto-mounts macOS `/Users/` into the VM — edit on macOS, compile on Linux. No rsync or Docker volumes needed. +OrbStack auto-mounts the macOS filesystem (including `/Volumes/`) into the VM at the same paths — edit on macOS, compile on Linux. No rsync or Docker volumes needed. `orb run` preserves the caller's working directory, so commands run from the repo need no `cd` at all. + +> **⚠ Stale checkout trap:** `/Users/tindang/workspaces/tind-repo/moon` is an OLD second checkout (stuck at hash-ttl era). The live repo is `/Volumes/Games/tindang-repo/moon` — never `cd` to the old path in VM commands, and pin `MOON_BIN` explicitly for integration tests that spawn a server binary (`find_moon_binary()` falls back to `target/release/moon`, whose provenance is unknown). ### Commands ```bash # Build (release) -orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Users/tindang/workspaces/tind-repo/moon && cargo build --release' +orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Volumes/Games/tindang-repo/moon && cargo build --release' # Test (all) -orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Users/tindang/workspaces/tind-repo/moon && cargo test --release' +orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Volumes/Games/tindang-repo/moon && cargo test --release' # Test (tokio runtime, CI parity) -orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Users/tindang/workspaces/tind-repo/moon && cargo test --no-default-features --features runtime-tokio,jemalloc' +orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Volumes/Games/tindang-repo/moon && cargo test --no-default-features --features runtime-tokio,jemalloc' # Clippy -orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Users/tindang/workspaces/tind-repo/moon && cargo clippy -- -D warnings' +orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Volumes/Games/tindang-repo/moon && cargo clippy -- -D warnings' # Run server -orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Users/tindang/workspaces/tind-repo/moon && ./target/release/moon --port 6399 --shards 4' +orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Volumes/Games/tindang-repo/moon && ./target/release/moon --port 6399 --shards 4' # Benchmark (redis-benchmark from macOS can reach moon-dev via OrbStack networking) -orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Users/tindang/workspaces/tind-repo/moon && cargo bench' +orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Volumes/Games/tindang-repo/moon && cargo bench' # Interactive shell orb run -m moon-dev bash @@ -70,8 +72,11 @@ orb run -m moon-dev bash -c 'sudo apt-get update -qq && sudo apt-get install -y - **`cargo build`/`cargo test` on macOS is now fully supported** — macOS is a first-class target. - Use `orb run -m moon-dev` for Linux-specific testing (io_uring, O_DIRECT, connection migration). - All **benchmark numbers** MUST come from the Linux VM (or GCloud instances). -- The VM path to the repo is the same as macOS: `/Users/tindang/workspaces/tind-repo/moon`. +- The VM path to the repo is the same as macOS: `/Volumes/Games/tindang-repo/moon`. - Use `source ~/.cargo/env &&` prefix in every `orb run` command. +- Use `CARGO_TARGET_DIR=target-linux` for VM builds of the shared checkout so Linux ELF and macOS Mach-O artifacts never clobber each other (`/target-linux/` is gitignored). +- **Diskfull guard:** Moon pauses writes (`MOONERR diskfull`) when the data dir's filesystem has <5% free. `/Volumes/Games` hovers near that line — run server-spawning suites (e.g. `scripts/test-consistency.sh`) from a VM-local clone (`git clone --depth 1 file:///Volumes/Games/tindang-repo/moon ~/moon-consistency`) or pass a fresh `--dir` on VM /tmp. +- Don't edit sources on macOS while a VM build of the same checkout is compiling (shared fs → spurious compile errors). ## Scripts @@ -249,5 +254,32 @@ Many style lints are suppressed in `src/lib.rs` (`#![allow(...)]`). Correctness Before pushing, run the full CI matrix locally: ```bash -orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Users/tindang/workspaces/tind-repo/moon && cargo fmt --check && cargo clippy -- -D warnings && cargo clippy --no-default-features --features runtime-tokio,jemalloc -- -D warnings && cargo test --release && cargo test --no-default-features --features runtime-tokio,jemalloc' +orb run -m moon-dev bash -c 'source ~/.cargo/env && cd /Volumes/Games/tindang-repo/moon && cargo fmt --check && cargo clippy -- -D warnings && cargo clippy --no-default-features --features runtime-tokio,jemalloc -- -D warnings && cargo test --release && cargo test --no-default-features --features runtime-tokio,jemalloc' ``` + +<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside --> +## ADD — how to work in this repo + +This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build; +the human owns direction and verification. The loop below works for any agent — +Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code: + +1. Run `python3 .add/tooling/add.py status` — where the project is and what's + next (the resume point; read it first every session). +2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task + builds on. +3. Run `python3 .add/tooling/add.py guide` — it names the phase and the exact + phase-guide file to read (the `guide :` line). Work ONLY that phase — each + guide ends with its exit gate and the command to move on. + +The flow: INTAKE sizes a request into a milestone; each task runs the +**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle, +ONE human approval at the frozen contract — then a self-driving build→verify +run. Non-negotiable for every agent: +Never weaken a test or edit a frozen contract to make a build pass; a security +finding is always HARD-STOP — never auto-passed. + +On Claude Code the `add` skill drives this loop automatically; other agents +follow the three steps. The book is in `.add/docs/`. This block is generated +by `add.py sync-guidelines`; edit outside the markers, not inside. +<!-- ADD:END --> diff --git a/src/admin/metrics_setup.rs b/src/admin/metrics_setup.rs index c7e062fed..cde30fce5 100644 --- a/src/admin/metrics_setup.rs +++ b/src/admin/metrics_setup.rs @@ -23,10 +23,87 @@ pub fn is_server_ready() -> bool { // ── Lightweight atomic counters for INFO ──────────────────────────────── // These counters work even when the Prometheus exporter is disabled // (admin_port=0), so INFO always returns meaningful stats. -static TOTAL_COMMANDS: AtomicU64 = AtomicU64::new(0); static TOTAL_CONNECTIONS: AtomicU64 = AtomicU64::new(0); static CONNECTED_CLIENTS: AtomicU64 = AtomicU64::new(0); +// ── spsc-wake-floor (M5): event-driven wake observability ─────────────── +// Bumped at most once per shard-loop wake / per capped drain cycle — never +// per command — so plain (unsharded) atomics are fine here. +static SPSC_NOTIFY_WAKES: AtomicU64 = AtomicU64::new(0); +static SPSC_DRAIN_RENOTIFY: AtomicU64 = AtomicU64::new(0); + +/// Count a shard-loop wake that came from the cross-shard `Notify` arm +/// (event-driven drain) rather than the periodic timer. +/// +/// Includes wakes caused by the drain-cap self-re-notify: the `flume +/// bounded(1)` token coalesces producer notifies and self-re-notifies, so +/// they are indistinguishable at the wake site. Producer-driven wakes ≈ +/// `spsc_notify_wakes - spsc_drain_renotify` (approximate — a coalesced +/// token can carry both causes). +#[inline] +pub fn bump_spsc_notify_wake() { + SPSC_NOTIFY_WAKES.fetch_add(1, Ordering::Relaxed); +} + +/// Total shard-loop wakes driven by the cross-shard `Notify` (for INFO Stats). +#[inline] +pub fn spsc_notify_wakes() -> u64 { + SPSC_NOTIFY_WAKES.load(Ordering::Relaxed) +} + +/// Count a self-re-notify issued because `drain_spsc_shared` stopped at the +/// per-cycle drain cap with messages possibly remaining. +#[inline] +pub fn bump_spsc_drain_renotify() { + SPSC_DRAIN_RENOTIFY.fetch_add(1, Ordering::Relaxed); +} + +/// Total capped-drain self-re-notifies (for INFO Stats). +#[inline] +pub fn spsc_drain_renotify() -> u64 { + SPSC_DRAIN_RENOTIFY.load(Ordering::Relaxed) +} + +// ── QW4 (2026-06 review finding 1.6): sharded total-commands counter ──── +// Previously a single `TOTAL_COMMANDS: AtomicU64` — one cache line bounced +// across every shard core at full command rate (false sharing). Each OS +// thread takes a padded slot (round-robin at first use); increments touch +// only that thread's line. Readers (INFO, server_stats tick) sum all slots; +// the sum is exact — every increment lands in exactly one slot. +const COMMAND_COUNTER_SLOTS: usize = 64; + +#[repr(align(64))] +struct PaddedCounter(AtomicU64); + +#[allow(clippy::declare_interior_mutable_const)] // template for static array init only +const PADDED_COUNTER_ZERO: PaddedCounter = PaddedCounter(AtomicU64::new(0)); +static COMMAND_COUNTERS: [PaddedCounter; COMMAND_COUNTER_SLOTS] = + [PADDED_COUNTER_ZERO; COMMAND_COUNTER_SLOTS]; +static NEXT_COMMAND_COUNTER_SLOT: AtomicU64 = AtomicU64::new(0); + +thread_local! { + static COMMAND_COUNTER_SLOT: usize = + (NEXT_COMMAND_COUNTER_SLOT.fetch_add(1, Ordering::Relaxed) as usize) + % COMMAND_COUNTER_SLOTS; +} + +/// Increment this thread's slot of the sharded total-commands counter. +#[inline] +fn bump_total_commands() { + COMMAND_COUNTER_SLOT.with(|&slot| { + COMMAND_COUNTERS[slot].0.fetch_add(1, Ordering::Relaxed); + }); +} + +/// Exact sum across all counter slots. O(64) loads — read paths only +/// (INFO, the 1s server_stats tick), never the command hot path. +fn total_commands_sum() -> u64 { + COMMAND_COUNTERS + .iter() + .map(|c| c.0.load(Ordering::Relaxed)) + .sum() +} + // ── P6: WAL aggressive reclamation counters (read by P10 INFO emitter) ─── // Incremented by WalWriterV3::recycle_aggressive(). P10 reads these via the // public getters below to populate the `# Reclamation` INFO section. @@ -398,7 +475,7 @@ fn sanitize_cmd_label(cmd: &str) -> &'static str { /// Record a command execution. #[inline] pub fn record_command(cmd: &str, latency_us: u64) { - TOTAL_COMMANDS.fetch_add(1, Ordering::Relaxed); + bump_total_commands(); if !METRICS_INITIALIZED.load(Ordering::Relaxed) { return; } @@ -415,7 +492,7 @@ pub fn record_command(cmd: &str, latency_us: u64) { /// would otherwise bias the distribution with a zero value. #[inline] pub fn record_command_no_latency(cmd: &str) { - TOTAL_COMMANDS.fetch_add(1, Ordering::Relaxed); + bump_total_commands(); if !METRICS_INITIALIZED.load(Ordering::Relaxed) { return; } @@ -495,7 +572,7 @@ impl CachedMetricsHandles { /// recorder-backend DashMap lookup on cache hit. #[inline] pub fn record_command_cached(cmd: &str, latency_us: u64, cache: &mut CachedMetricsHandles) { - TOTAL_COMMANDS.fetch_add(1, Ordering::Relaxed); + bump_total_commands(); if !METRICS_INITIALIZED.load(Ordering::Relaxed) { return; } @@ -509,7 +586,7 @@ pub fn record_command_cached(cmd: &str, latency_us: u64, cache: &mut CachedMetri /// the recorder-backend DashMap lookup on cache hit. #[inline] pub fn record_command_no_latency_cached(cmd: &str, cache: &mut CachedMetricsHandles) { - TOTAL_COMMANDS.fetch_add(1, Ordering::Relaxed); + bump_total_commands(); if !METRICS_INITIALIZED.load(Ordering::Relaxed) { return; } @@ -1080,7 +1157,7 @@ pub fn get_rss_bytes() -> u64 { /// Total commands processed since server start (for INFO Stats). #[inline] pub fn total_commands_processed() -> u64 { - TOTAL_COMMANDS.load(Ordering::Relaxed) + total_commands_sum() } /// Total connections received since server start (for INFO Stats). @@ -1259,7 +1336,7 @@ pub fn spawn_metrics_publisher() { None => continue, }; - let total_ops = TOTAL_COMMANDS.load(Ordering::Relaxed); + let total_ops = total_commands_sum(); let ops_per_sec = total_ops.saturating_sub(prev_ops); prev_ops = total_ops; diff --git a/src/client_registry.rs b/src/client_registry.rs index 0d71ec571..c091997e4 100644 --- a/src/client_registry.rs +++ b/src/client_registry.rs @@ -1,32 +1,63 @@ //! Global client connection registry for CLIENT LIST/INFO/KILL. //! //! Every connection registers on accept and deregisters on close. -//! The registry is a global `parking_lot::RwLock<HashMap>` — not on -//! the command hot path (only touched on connect/disconnect and CLIENT commands). +//! The registry is a global `parking_lot::RwLock<HashMap>` touched only on +//! connect/disconnect and CLIENT commands. Per-batch state (db, idle time, +//! flags, kill checks) flows through the lock-free [`ClientLiveState`] handle +//! that `register` returns (QW8, 2026-06 review finding 1.3 — previously the +//! steady-state loop took the global write lock after every pipeline batch +//! and the global read lock for every kill check). use parking_lot::RwLock; use std::collections::HashMap; -use std::sync::LazyLock; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, LazyLock}; use std::time::Instant; /// Global client registry. static REGISTRY: LazyLock<RwLock<HashMap<u64, ClientEntry>>> = LazyLock::new(|| RwLock::new(HashMap::new())); +/// Lock-free per-connection state, shared between the connection task +/// (writer, once per batch) and CLIENT LIST/INFO/KILL (occasional readers). +pub struct ClientLiveState { + pub connected_at: Instant, + pub db: AtomicUsize, + /// Milliseconds since `connected_at` of the last completed batch. + pub last_cmd_ms: AtomicU64, + /// Bit-packed [`ClientFlags`] (see `ClientFlags::to_bits`). + pub flags: AtomicU8, + /// Set by CLIENT KILL — the handler checks this and closes the connection. + pub kill_flag: AtomicBool, +} + +impl ClientLiveState { + /// Record batch-completion state. Three relaxed stores — no lock. + #[inline] + pub fn touch(&self, db: usize, flags: ClientFlags) { + self.db.store(db, Ordering::Relaxed); + self.last_cmd_ms.store( + self.connected_at.elapsed().as_millis() as u64, + Ordering::Relaxed, + ); + self.flags.store(flags.to_bits(), Ordering::Relaxed); + } + + /// Lock-free CLIENT KILL check for the connection's own loop. + #[inline] + pub fn is_killed(&self) -> bool { + self.kill_flag.load(Ordering::Relaxed) + } +} + /// Information about a connected client. pub struct ClientEntry { pub id: u64, pub addr: String, pub name: Option<String>, pub user: String, - pub db: usize, pub shard: usize, - pub flags: ClientFlags, - pub connected_at: Instant, - pub last_cmd_at: Instant, - /// Set by CLIENT KILL — handler checks this and closes the connection. - pub kill_flag: AtomicBool, + pub live: Arc<ClientLiveState>, } /// Client connection flags (matches Redis CLIENT LIST flag characters). @@ -50,24 +81,46 @@ impl ClientFlags { "N" } } + + /// Pack into one byte for `ClientLiveState::flags`. + #[inline] + pub fn to_bits(self) -> u8 { + (self.subscriber as u8) | ((self.in_multi as u8) << 1) | ((self.blocked as u8) << 2) + } + + /// Unpack from `ClientLiveState::flags`. + #[inline] + pub fn from_bits(bits: u8) -> Self { + ClientFlags { + subscriber: bits & 1 != 0, + in_multi: bits & 2 != 0, + blocked: bits & 4 != 0, + } + } } /// Register a new client connection. -pub fn register(id: u64, addr: String, user: String, shard: usize) { - let now = Instant::now(); +/// +/// Returns the connection's lock-free live-state handle; the connection task +/// keeps it for per-batch `touch()` and `is_killed()` without the registry lock. +pub fn register(id: u64, addr: String, user: String, shard: usize) -> Arc<ClientLiveState> { + let live = Arc::new(ClientLiveState { + connected_at: Instant::now(), + db: AtomicUsize::new(0), + last_cmd_ms: AtomicU64::new(0), + flags: AtomicU8::new(ClientFlags::default().to_bits()), + kill_flag: AtomicBool::new(false), + }); let entry = ClientEntry { id, addr, name: None, user, - db: 0, shard, - flags: ClientFlags::default(), - connected_at: now, - last_cmd_at: now, - kill_flag: AtomicBool::new(false), + live: Arc::clone(&live), }; REGISTRY.write().insert(id, entry); + live } /// Deregister a client connection. @@ -75,7 +128,9 @@ pub fn deregister(id: u64) { REGISTRY.write().remove(&id); } -/// Update mutable fields for a client (called periodically or on state change). +/// Update mutable fields for a client (CLIENT SETNAME and similar — rare, +/// never the steady-state batch loop; batch state goes through the +/// [`ClientLiveState`] handle instead). pub fn update<F: FnOnce(&mut ClientEntry)>(id: u64, f: F) { if let Some(entry) = REGISTRY.write().get_mut(&id) { f(entry); @@ -83,11 +138,11 @@ pub fn update<F: FnOnce(&mut ClientEntry)>(id: u64, f: F) { } /// Check if a client has been marked for killing. +/// +/// Registry-lookup variant for code without the live handle; connection +/// loops use `ClientLiveState::is_killed` (lock-free) instead. pub fn is_killed(id: u64) -> bool { - REGISTRY - .read() - .get(&id) - .is_some_and(|e| e.kill_flag.load(Ordering::Relaxed)) + REGISTRY.read().get(&id).is_some_and(|e| e.live.is_killed()) } /// Format all clients as a CLIENT LIST string. @@ -133,7 +188,7 @@ pub fn kill_clients(filter: &KillFilter) -> u64 { KillFilter::User(user) => entry.user == *user, }; if matches { - entry.kill_flag.store(true, Ordering::Relaxed); + entry.live.kill_flag.store(true, Ordering::Relaxed); count += 1; } } @@ -183,16 +238,19 @@ pub fn parse_kill_args(args: &[&[u8]]) -> Option<KillFilter> { fn format_client_line(buf: &mut String, entry: &ClientEntry, now: Instant) { use std::fmt::Write; - let age = now.duration_since(entry.connected_at).as_secs(); - let idle = now.duration_since(entry.last_cmd_at).as_secs(); + let live = &*entry.live; + let age = now.duration_since(live.connected_at).as_secs(); + let last_cmd_secs = live.last_cmd_ms.load(Ordering::Relaxed) / 1000; + let idle = age.saturating_sub(last_cmd_secs); let name = entry.name.as_deref().unwrap_or(""); - let flags = entry.flags.to_flag_str(); + let flags = ClientFlags::from_bits(live.flags.load(Ordering::Relaxed)).to_flag_str(); + let db = live.db.load(Ordering::Relaxed); let _ = writeln!( buf, "id={} addr={} fd=0 name={} db={} sub=0 psub=0 ssub=0 multi=-1 \ watch=0 qbuf=0 qbuf-free=0 argv-mem=0 tot-mem=0 net-i=0 net-o=0 \ age={} idle={} flags={} user={}", - entry.id, entry.addr, name, entry.db, age, idle, flags, entry.user, + entry.id, entry.addr, name, db, age, idle, flags, entry.user, ); } @@ -227,11 +285,13 @@ mod tests { #[test] fn test_kill_by_id() { let id = 999_002; - register(id, "10.0.0.2:6000".into(), "bob".into(), 0); + let live = register(id, "10.0.0.2:6000".into(), "bob".into(), 0); assert!(!is_killed(id)); + assert!(!live.is_killed()); let count = kill_clients(&KillFilter::Id(id)); assert_eq!(count, 1); assert!(is_killed(id)); + assert!(live.is_killed(), "live handle observes the kill lock-free"); deregister(id); } @@ -250,19 +310,26 @@ mod tests { } #[test] - fn test_update() { + fn test_update_and_touch() { let id = 999_003; - register(id, "10.0.0.5:8000".into(), "default".into(), 0); + let live = register(id, "10.0.0.5:8000".into(), "default".into(), 0); update(id, |e| { e.name = Some("myconn".into()); - e.db = 3; }); + live.touch(3, ClientFlags::default()); let info = client_info(id).unwrap(); assert!(info.contains("name=myconn")); assert!(info.contains("db=3")); deregister(id); } + #[test] + fn test_flags_bits_roundtrip() { + for bits in 0..8u8 { + assert_eq!(ClientFlags::from_bits(bits).to_bits(), bits); + } + } + #[test] fn test_parse_kill_args() { let args: Vec<&[u8]> = vec![b"ID", b"42"]; diff --git a/src/command/connection.rs b/src/command/connection.rs index 3328a9d95..f22310f15 100644 --- a/src/command/connection.rs +++ b/src/command/connection.rs @@ -265,11 +265,15 @@ pub fn info(db: &Database, _args: &[Frame]) -> Frame { "total_commands_processed:{}\r\n\ total_connections_received:{}\r\n\ total_dispatch_cross_read_fastpath:{}\r\n\ - total_dispatch_cross_spsc:{}\r\n", + total_dispatch_cross_spsc:{}\r\n\ + spsc_notify_wakes:{}\r\n\ + spsc_drain_renotify:{}\r\n", crate::admin::metrics_setup::total_commands_processed(), crate::admin::metrics_setup::total_connections_received(), crate::admin::metrics_setup::total_dispatch_cross_read_fastpath(), crate::admin::metrics_setup::total_dispatch_cross_spsc(), + crate::admin::metrics_setup::spsc_notify_wakes(), + crate::admin::metrics_setup::spsc_drain_renotify(), ); sections.push_str("\r\n"); diff --git a/src/replication/backlog.rs b/src/replication/backlog.rs index 7209a86e3..8a3ff678e 100644 --- a/src/replication/backlog.rs +++ b/src/replication/backlog.rs @@ -31,15 +31,27 @@ impl ReplicationBacklog { } /// Append bytes to the backlog. Evicts oldest bytes when at capacity. + /// + /// Bulk-copy implementation (QW5, 2026-06 review finding 1.5): one drain + /// plus one extend instead of a per-byte eviction loop. State machine is + /// identical to the per-byte version — the live window is always the last + /// `capacity` bytes ever appended, and `start_offset` maintains the + /// invariant `start_offset = end_offset - buf.len()`. pub fn append(&mut self, data: &[u8]) { - for &b in data { - if self.buf.len() == self.capacity { - self.buf.pop_front(); - self.start_offset += 1; + self.end_offset += data.len() as u64; + if data.len() >= self.capacity { + // The live window comes entirely from the tail of `data`. + self.buf.clear(); + self.buf + .extend(data[data.len() - self.capacity..].iter().copied()); + } else { + let overflow = (self.buf.len() + data.len()).saturating_sub(self.capacity); + if overflow > 0 { + self.buf.drain(..overflow); } - self.buf.push_back(b); + self.buf.extend(data.iter().copied()); } - self.end_offset += data.len() as u64; + self.start_offset = self.end_offset - self.buf.len() as u64; } /// Returns owned Vec of bytes from `offset` to end_offset, or None if offset was evicted. diff --git a/src/replication/state.rs b/src/replication/state.rs index a9b57317d..f75ec32de 100644 --- a/src/replication/state.rs +++ b/src/replication/state.rs @@ -14,10 +14,13 @@ pub struct ReplicationState { /// Secondary replication ID (previous master's ID). Used after failover. pub repl_id2: String, /// Per-shard write offset (monotonic bytes appended, NEVER resets on WAL truncation). - /// Length = num_shards. - pub shard_offsets: Vec<AtomicU64>, + /// Length = num_shards. Arc'd so `offset_handle()` can hand shards a + /// lock-free clone — the per-write advance must never take the + /// surrounding `RwLock` (QW3, 2026-06 review). + pub shard_offsets: Arc<[AtomicU64]>, /// Sum of all shard offsets -- global master replication offset. - pub master_repl_offset: AtomicU64, + /// Arc'd for the same lock-free `offset_handle()` distribution. + pub master_repl_offset: Arc<AtomicU64>, /// Connected replicas (master mode). Guarded by Arc<RwLock<ReplicationState>> callers. pub replicas: Vec<ReplicaInfo>, /// Per-shard replication backlogs, shared between the shard event loop @@ -59,7 +62,7 @@ impl ReplicationState { repl_id, repl_id2, shard_offsets: (0..num_shards).map(|_| AtomicU64::new(0)).collect(), - master_repl_offset: AtomicU64::new(0), + master_repl_offset: Arc::new(AtomicU64::new(0)), replicas: Vec::new(), per_shard_backlogs: (0..num_shards) .map(|_| Arc::new(parking_lot::Mutex::new(None))) @@ -172,6 +175,47 @@ impl ReplicationState { .map(|o| o.load(Ordering::Relaxed)) .unwrap_or(0) } + + /// Clone out a lock-free handle to the offset atomics. + /// + /// Called once per shard at event-loop startup; the handle is what the + /// per-write path uses, so `RwLock<ReplicationState>` is never read-locked + /// per write (QW3, 2026-06 review finding 1.4). + pub fn offset_handle(&self) -> OffsetHandle { + OffsetHandle { + shard_offsets: Arc::clone(&self.shard_offsets), + master_repl_offset: Arc::clone(&self.master_repl_offset), + } + } +} + +/// Lock-free handle to the replication offset atomics, distributed to each +/// shard at startup via [`ReplicationState::offset_handle`]. Advancing +/// offsets through this handle is equivalent to `ReplicationState::issue_lsn` +/// — both operate on the same `Arc`'d atomics. +#[derive(Clone)] +pub struct OffsetHandle { + shard_offsets: Arc<[AtomicU64]>, + master_repl_offset: Arc<AtomicU64>, +} + +impl OffsetHandle { + /// See [`ReplicationState::issue_lsn`] — same semantics, same atomics, + /// no surrounding lock. + #[inline] + pub fn issue_lsn(&self, shard_id: usize, delta: u64) -> u64 { + if shard_id >= self.shard_offsets.len() { + return 0; + } + self.shard_offsets[shard_id].fetch_add(delta, Ordering::Relaxed); + self.master_repl_offset.fetch_add(delta, Ordering::Relaxed) + } + + /// See [`ReplicationState::increment_shard_offset`]. + #[inline] + pub fn increment_shard_offset(&self, shard_id: usize, delta: u64) { + let _ = self.issue_lsn(shard_id, delta); + } } const ZEROED_ID: &str = "0000000000000000000000000000000000000000"; diff --git a/src/runtime/channel.rs b/src/runtime/channel.rs index 58895efb2..b9fee8fea 100644 --- a/src/runtime/channel.rs +++ b/src/runtime/channel.rs @@ -67,7 +67,8 @@ impl<T: Send + 'static> OneshotReceiver<T> { } impl<T: Send> OneshotReceiver<T> { - /// Non-blocking try_recv for use with pending_wakers polling pattern. + /// Non-blocking try_recv — fast path before awaiting the receiver as a + /// Future (cross-thread wake works on both runtimes; see swf0). /// /// Returns `Ok(value)` if a value is available, `Err(TryRecvEmpty)` if not yet, /// or `Err(TryRecvDisconnected)` if the sender was dropped or rx was taken. diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 0a8a1ca5d..8ebf35392 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -22,6 +22,7 @@ compile_error!("No runtime selected. Enable either `runtime-tokio` or `runtime-m pub mod cancel; pub mod channel; +pub mod race; pub mod traits; #[cfg(feature = "runtime-tokio")] diff --git a/src/runtime/race.rs b/src/runtime/race.rs new file mode 100644 index 000000000..fb7acc939 --- /dev/null +++ b/src/runtime/race.rs @@ -0,0 +1,108 @@ +//! Allocation-free two-arm race future (spsc-wake-floor, M1/M6). +//! +//! The monoio shard loop needs to wake on EITHER its periodic timer OR a +//! cross-shard `Notify` — but `monoio::select!` is banned in this codebase +//! (known memory leak), and the tokio `select!` macro is runtime-specific. +//! `race2` is the minimal hand-rolled alternative: poll the first arm, then +//! the second, resolve on the first `Ready`. No allocation, no waker +//! wrapping — both arms see the caller's `Context` directly, so whichever +//! wakes last still wakes the race. +//! +//! The losing arm is NOT polled after the race resolves; the caller drops it. +//! Dropping a flume `RecvFut` deregisters its hook and re-queues an +//! undelivered token (pinned by `swf_a3_notify_token_survives_poll_drop` and +//! `losing_notified_arm_keeps_token`), so a race loop over +//! `Notify::notified()` has no lost-wake window. + +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; + +/// Which arm of a [`race2`] completed first. +#[derive(Debug)] +pub enum Arm<A, B> { + /// The first arm resolved (it has priority on simultaneous readiness). + First(A), + /// The second arm resolved while the first was still pending. + Second(B), +} + +/// Future returned by [`race2`]. +pub struct Race2<'a, A: Future, B: Future> { + a: Pin<&'a mut A>, + b: Pin<&'a mut B>, +} + +/// Race two pinned futures; resolves with the first `Ready` arm. The first +/// arm is polled first on every wake, so it wins ties deterministically. +pub fn race2<'a, A: Future, B: Future>(a: Pin<&'a mut A>, b: Pin<&'a mut B>) -> Race2<'a, A, B> { + Race2 { a, b } +} + +impl<A: Future, B: Future> Future for Race2<'_, A, B> { + type Output = Arm<A::Output, B::Output>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { + // Race2 holds `Pin<&mut _>` fields, which are always Unpin, so the + // struct itself is Unpin and `get_mut` is safe-by-construction. + let this = self.get_mut(); + if let Poll::Ready(out) = this.a.as_mut().poll(cx) { + return Poll::Ready(Arm::First(out)); + } + if let Poll::Ready(out) = this.b.as_mut().poll(cx) { + return Poll::Ready(Arm::Second(out)); + } + Poll::Pending + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::pin::pin; + + #[test] + fn first_arm_priority_on_double_ready() { + let outcome = futures::executor::block_on(async { + let a = pin!(std::future::ready(1u8)); + let b = pin!(std::future::ready(2u8)); + race2(a, b).await + }); + assert!(matches!(outcome, Arm::First(1))); + } + + #[test] + fn second_arm_resolves_when_first_pending() { + let outcome = futures::executor::block_on(async { + let a = pin!(std::future::pending::<u8>()); + let b = pin!(std::future::ready(2u8)); + race2(a, b).await + }); + assert!(matches!(outcome, Arm::Second(2))); + } + + #[test] + fn pending_then_woken_arm_resolves() { + // A future that is Pending once, then Ready — the race must pass the + // caller's waker through so the wake reaches the executor. + struct ReadyOnSecondPoll(bool); + impl Future for ReadyOnSecondPoll { + type Output = u8; + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u8> { + if self.0 { + Poll::Ready(7) + } else { + self.0 = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + } + } + let outcome = futures::executor::block_on(async { + let a = pin!(std::future::pending::<u8>()); + let b = pin!(ReadyOnSecondPoll(false)); + race2(a, b).await + }); + assert!(matches!(outcome, Arm::Second(7))); + } +} diff --git a/src/server/conn/handler_monoio/dispatch.rs b/src/server/conn/handler_monoio/dispatch.rs index 64c76415f..bce590074 100644 --- a/src/server/conn/handler_monoio/dispatch.rs +++ b/src/server/conn/handler_monoio/dispatch.rs @@ -852,22 +852,31 @@ pub(super) fn try_handle_client_admin( if let Some(sub_bytes) = extract_bytes(sub) { if sub_bytes.eq_ignore_ascii_case(b"LIST") { crate::client_registry::update(client_id, |e| { - e.db = conn.selected_db; - e.last_cmd_at = std::time::Instant::now(); - e.flags = crate::client_registry::ClientFlags { - subscriber: conn.subscription_count > 0, - in_multi: conn.in_multi, - blocked: false, - }; + e.live.touch( + conn.selected_db, + crate::client_registry::ClientFlags { + subscriber: conn.subscription_count > 0, + in_multi: conn.in_multi, + blocked: false, + }, + ); }); let list = crate::client_registry::client_list(); responses.push(Frame::BulkString(Bytes::from(list))); return true; } if sub_bytes.eq_ignore_ascii_case(b"INFO") { + // Derive flags from CURRENT conn state (same as the LIST path + // above) — reloading e.live.flags would freeze stale bits. crate::client_registry::update(client_id, |e| { - e.db = conn.selected_db; - e.last_cmd_at = std::time::Instant::now(); + e.live.touch( + conn.selected_db, + crate::client_registry::ClientFlags { + subscriber: conn.subscription_count > 0, + in_multi: conn.in_multi, + blocked: false, + }, + ); }); let info = crate::client_registry::client_info(client_id).unwrap_or_default(); responses.push(Frame::BulkString(Bytes::from(info))); diff --git a/src/server/conn/handler_monoio/mod.rs b/src/server/conn/handler_monoio/mod.rs index f895daece..2004c4a0c 100644 --- a/src/server/conn/handler_monoio/mod.rs +++ b/src/server/conn/handler_monoio/mod.rs @@ -40,22 +40,31 @@ use crate::framevec; use crate::pubsub::subscriber::Subscriber; use crate::server::codec::RespCodec; use crate::shard::dispatch::ShardMessage; -// ResponseSlotPool NOT used on monoio — its AtomicWaker doesn't cross -// monoio's single-threaded (!Send) executor boundary. Use oneshot channels. +// ResponseSlotPool is not used on monoio (yet): this handler predates the +// proof that cross-thread wakes DO reach monoio tasks (the `sync` feature's +// waker channel + driver unpark — see tests/spsc_wake_floor_red.rs::swf0). +// The flume oneshot per batch works the same way; unifying on the +// zero-allocation ResponseSlotPool is a candidate follow-up. // ── F3: cross-shard dispatch backpressure / response-wait bounds ── // Design-for-failure: a wedged or saturated target shard must surface a // bounded error, never park the connection (holding its buffers) forever. // Push-retry bounds live in `crate::shard::dispatch` (shared with the tokio -// handler); the response-wait bound below is monoio-specific (the tokio path -// awaits via `ResponseSlotPool`, not this busy-wait relay). +// handler); the response-wait bounds below are monoio-specific (the tokio +// path awaits via `ResponseSlotPool`, not a flume oneshot). -/// Max ~1ms event-loop wake cycles to wait for a cross-shard reply before -/// declaring the response lost. The batch was already dispatched, so this is -/// an *uncertain write* backstop (the command may have applied on the target) -/// — set generously (~30s) and primarily guarded by the shutdown check. +/// Chunk length for the bounded cross-shard reply wait (M2, spsc-wake-floor). +/// Replies normally wake the task directly (cross-thread waker via monoio's +/// `sync` feature); the chunking only bounds how long a shutdown can go +/// unnoticed when the reply never arrives. #[cfg(feature = "runtime-monoio")] -const CROSS_SHARD_RESPONSE_MAX_WAITS: u32 = 30_000; +const CROSS_SHARD_RESPONSE_CHUNK_MS: u64 = 100; + +/// Total reply-wait budget before declaring the response lost. The batch was +/// already dispatched, so this is an *uncertain write* backstop (the command +/// may have applied on the target) — set generously (~30s). +#[cfg(feature = "runtime-monoio")] +const CROSS_SHARD_RESPONSE_TIMEOUT_MS: u64 = 30_000; /// Result of `handle_connection_sharded_monoio` execution. /// @@ -122,7 +131,11 @@ pub(crate) async fn handle_connection_sharded_monoio< client_id: u64, can_migrate: bool, initial_read_buf: BytesMut, - pending_wakers: Rc<RefCell<Vec<std::task::Waker>>>, + // Event-loop waker relay. No longer used by this handler — the cross-shard + // reply path awaits its oneshot directly (M2, spsc-wake-floor; cross-thread + // wake proven by swf0). The relay plumbing is kept for future registrants + // and the event loop still sweeps it every iteration. + _pending_wakers: Rc<RefCell<Vec<std::task::Waker>>>, migrated_state: Option<&MigratedConnectionState>, ) -> (MonoioHandlerResult, Option<S>) { use monoio::io::AsyncWriteRentExt; @@ -153,7 +166,7 @@ pub(crate) async fn handle_connection_sharded_monoio< let db_count = ctx.shard_databases.db_count(); // Register in global client registry for CLIENT LIST/INFO/KILL. - crate::client_registry::register( + let client_live = crate::client_registry::register( client_id, peer_addr.clone(), conn.current_user.clone(), @@ -196,8 +209,8 @@ pub(crate) async fn handle_connection_sharded_monoio< let mut frames: Vec<Frame> = Vec::with_capacity(64); loop { - // Check if CLIENT KILL targeted this connection - if crate::client_registry::is_killed(client_id) { + // Check if CLIENT KILL targeted this connection (lock-free, QW8) + if client_live.is_killed() { break; } @@ -1987,58 +2000,67 @@ pub(crate) async fn handle_connection_sharded_monoio< oneshot_futures.push((target, meta, reply_rx)); } - // Poll all shard responses via pending_wakers relay (monoio cross-thread waker fix). - // monoio's !Send executor doesn't see cross-thread Waker::wake() from flume. - // Instead, the connection task registers its waker in pending_wakers; the event - // loop drains and wakes them after every SPSC cycle (~1ms). On wake, try_recv() - // checks if the response arrived; if not, re-register and yield again. + // M2 (spsc-wake-floor): await each reply oneshot DIRECTLY. The executing + // shard's `send` wakes this task cross-thread — monoio 0.2.4's `sync` + // feature routes a remote Waker::wake() through a per-thread waker + // channel + driver unpark (eventfd on io_uring, kqueue wake on the + // legacy driver), proven at runtime by tests/spsc_wake_floor_red.rs:: + // swf0 on both drivers. The previous pending_wakers relay assumed this + // was impossible and polled on the shard loop's ~1ms tick — the relay + // sweep still runs in the event loop, but this path no longer uses it. for (target, meta, reply_rx) in oneshot_futures.drain(..) { tracing::trace!( - "Shard {}: awaiting cross-shard response via pending_wakers", + "Shard {}: awaiting cross-shard response (direct oneshot)", ctx.shard_id ); - let shard_responses = { - let pw = pending_wakers.clone(); - // F3: bound the response wait. The batch was already - // dispatched, so a missing reply is an *uncertain write* - // (the target shard may have applied it) — the error must - // NOT imply rejection. Break on disconnect (writer gone), - // shutdown, or the generous wait cap (~30s of ~1ms wakes). - let mut waits: u32 = 0; - loop { - match reply_rx.try_recv() { - Ok(value) => break Ok(value), - Err(flume::TryRecvError::Disconnected) => { - break Err("ERR cross-shard dispatch failed"); + // F3: bound the response wait. The batch was already dispatched, + // so a missing reply is an *uncertain write* (the target shard may + // have applied it) — the error must NOT imply rejection. Break on + // disconnect (sender gone), shutdown, or the generous ~30s cap. + // + // The wait is CHUNKED (race2 against a short sleep + is_cancelled + // check) instead of racing `shutdown.cancelled()`: CancelledFuture + // pushes a waker clone into the token's wakers Vec on every + // registration and never drains it until cancel fires — racing it + // per batch would accumulate wakers for the server's lifetime. + let shard_responses = match reply_rx.try_recv() { + // Fast path: pipelined batches often have the reply queued by + // the time this target is awaited — no future, no allocation. + Ok(value) => Ok(value), + Err(flume::TryRecvError::Disconnected) => { + Err("ERR cross-shard dispatch failed") + } + Err(flume::TryRecvError::Empty) => { + // OneshotReceiver is itself a Future with a cached inner + // recv future — pin it ONCE so its waker registration + // persists across chunk boundaries. + let mut recv = std::pin::pin!(reply_rx); + let mut waited_ms: u64 = 0; + loop { + if shutdown.is_cancelled() { + break Err("ERR cross-shard response aborted (shutdown)"); } - Err(flume::TryRecvError::Empty) => { - if shutdown.is_cancelled() { - break Err("ERR cross-shard response aborted (shutdown)"); - } - waits += 1; - if waits > CROSS_SHARD_RESPONSE_MAX_WAITS { - tracing::warn!( - "Shard {}: cross-shard response wait exhausted; \ - target may have applied the write", - ctx.shard_id - ); - break Err( - "ERR cross-shard response timeout (write may have applied)", - ); + let chunk = std::pin::pin!(monoio::time::sleep( + std::time::Duration::from_millis(CROSS_SHARD_RESPONSE_CHUNK_MS) + )); + match crate::runtime::race::race2(recv.as_mut(), chunk).await { + crate::runtime::race::Arm::First(Ok(value)) => break Ok(value), + crate::runtime::race::Arm::First(Err(_)) => { + break Err("ERR cross-shard dispatch failed"); } - // Yield once: register waker, return Pending, then Ready on wake. - let mut yielded = false; - std::future::poll_fn(|cx| { - if yielded { - std::task::Poll::Ready(()) - } else { - yielded = true; - pw.borrow_mut().push(cx.waker().clone()); - std::task::Poll::Pending + crate::runtime::race::Arm::Second(()) => { + waited_ms += CROSS_SHARD_RESPONSE_CHUNK_MS; + if waited_ms >= CROSS_SHARD_RESPONSE_TIMEOUT_MS { + tracing::warn!( + "Shard {}: cross-shard response wait exhausted; \ + target may have applied the write", + ctx.shard_id + ); + break Err( + "ERR cross-shard response timeout (write may have applied)", + ); } - }) - .await; - // After wake, loop back to try_recv + } } } } @@ -2116,16 +2138,16 @@ pub(crate) async fn handle_connection_sharded_monoio< } } - // Update registry with current state after each batch - crate::client_registry::update(client_id, |e| { - e.db = conn.selected_db; - e.last_cmd_at = std::time::Instant::now(); - e.flags = crate::client_registry::ClientFlags { + // Update live state after each batch — lock-free (QW8, 2026-06 + // review: this was a global registry write lock per batch). + client_live.touch( + conn.selected_db, + crate::client_registry::ClientFlags { subscriber: conn.subscription_count > 0, in_multi: conn.in_multi, blocked: false, - }; - }); + }, + ); // Check if migration was triggered during frame processing. // All responses for the current batch have been written, so the diff --git a/src/server/conn/handler_sharded/dispatch.rs b/src/server/conn/handler_sharded/dispatch.rs index 928b8ac5a..84eeab362 100644 --- a/src/server/conn/handler_sharded/dispatch.rs +++ b/src/server/conn/handler_sharded/dispatch.rs @@ -105,22 +105,31 @@ pub(super) fn try_handle_client_command( if sub_bytes.eq_ignore_ascii_case(b"LIST") { // Update our own entry before listing crate::client_registry::update(client_id, |e| { - e.db = conn.selected_db; - e.last_cmd_at = std::time::Instant::now(); - e.flags = crate::client_registry::ClientFlags { - subscriber: conn.subscription_count > 0, - in_multi: conn.in_multi, - blocked: false, - }; + e.live.touch( + conn.selected_db, + crate::client_registry::ClientFlags { + subscriber: conn.subscription_count > 0, + in_multi: conn.in_multi, + blocked: false, + }, + ); }); let list = crate::client_registry::client_list(); responses.push(Frame::BulkString(Bytes::from(list))); return true; } if sub_bytes.eq_ignore_ascii_case(b"INFO") { + // Derive flags from CURRENT conn state (same as the LIST path + // above) — reloading e.live.flags would freeze stale bits. crate::client_registry::update(client_id, |e| { - e.db = conn.selected_db; - e.last_cmd_at = std::time::Instant::now(); + e.live.touch( + conn.selected_db, + crate::client_registry::ClientFlags { + subscriber: conn.subscription_count > 0, + in_multi: conn.in_multi, + blocked: false, + }, + ); }); let info = crate::client_registry::client_info(client_id).unwrap_or_default(); responses.push(Frame::BulkString(Bytes::from(info))); diff --git a/src/server/conn/handler_sharded/mod.rs b/src/server/conn/handler_sharded/mod.rs index f32bd60ac..7bcf2241b 100644 --- a/src/server/conn/handler_sharded/mod.rs +++ b/src/server/conn/handler_sharded/mod.rs @@ -257,7 +257,7 @@ pub(crate) async fn handle_connection_sharded_inner< // Register in global client registry for CLIENT LIST/INFO/KILL. // RegistryGuard ensures deregister on all exit paths (including early returns). - crate::client_registry::register( + let client_live = crate::client_registry::register( client_id, peer_addr.clone(), conn.current_user.clone(), @@ -293,8 +293,8 @@ pub(crate) async fn handle_connection_sharded_inner< let mut break_outer = false; loop { - // Check if CLIENT KILL targeted this connection - if crate::client_registry::is_killed(client_id) { + // Check if CLIENT KILL targeted this connection (lock-free, QW8) + if client_live.is_killed() { break; } @@ -1864,16 +1864,16 @@ pub(crate) async fn handle_connection_sharded_inner< return (HandlerResult::Done, None); } - // Update registry with current state after each batch - crate::client_registry::update(client_id, |e| { - e.db = conn.selected_db; - e.last_cmd_at = std::time::Instant::now(); - e.flags = crate::client_registry::ClientFlags { + // Update live state after each batch — lock-free (QW8, 2026-06 + // review: this was a global registry write lock per batch). + client_live.touch( + conn.selected_db, + crate::client_registry::ClientFlags { subscriber: conn.subscription_count > 0, in_multi: conn.in_multi, blocked: false, - }; - }); + }, + ); // Check if migration was triggered during frame processing. // All responses for the current batch have been written, so the diff --git a/src/server/mod.rs b/src/server/mod.rs index 2ef4eadfb..cb601f6a0 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -7,6 +7,7 @@ pub mod expiration; pub mod listener; pub mod response_slot; pub mod shutdown; +pub mod socket_opts; // Backward-compatible re-export: callers using crate::server::connection::* still work pub mod connection { diff --git a/src/server/socket_opts.rs b/src/server/socket_opts.rs new file mode 100644 index 000000000..0482a986b --- /dev/null +++ b/src/server/socket_opts.rs @@ -0,0 +1,29 @@ +//! Socket options applied to accepted client connections. + +/// Enable TCP_NODELAY on an accepted client socket (QW1, 2026-06 review +/// finding 3.2). Redis defaults to `tcp-nodelay yes`; without it Nagle can +/// hold pipelined responses in the kernel send buffer for a delayed-ACK +/// window, turning sub-millisecond operations into ~40ms latency spikes. +/// +/// Generic over anything exposing the socket fd (`std`/`tokio` `TcpStream`, +/// `BorrowedFd` from the monoio/uring accept paths). +#[cfg(unix)] +pub fn apply_client_socket_opts<S: std::os::fd::AsFd>(sock: &S) -> std::io::Result<()> { + let sock_ref = socket2::SockRef::from(sock); + sock_ref.set_tcp_nodelay(true) +} + +#[cfg(test)] +mod tests { + #[test] + #[cfg(unix)] + fn sets_nodelay_on_accepted_socket() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let _client = std::net::TcpStream::connect(addr).unwrap(); + let (accepted, _) = listener.accept().unwrap(); + assert!(!accepted.nodelay().unwrap()); + super::apply_client_socket_opts(&accepted).unwrap(); + assert!(accepted.nodelay().unwrap()); + } +} diff --git a/src/shard/conn_accept.rs b/src/shard/conn_accept.rs index 7ccad7fdb..434968b4f 100644 --- a/src/shard/conn_accept.rs +++ b/src/shard/conn_accept.rs @@ -73,19 +73,22 @@ fn take_migration_read_buf(state: &mut MigratedConnectionState) -> BytesMut { std::mem::take(&mut state.read_buf_remainder) } -/// Set TCP keepalive on a raw file descriptor. +/// Apply per-connection socket options on a raw file descriptor. /// -/// Sets SO_KEEPALIVE and TCP_KEEPIDLE (Linux) / TCP_KEEPALIVE (macOS) to detect -/// dead connections. Called once per accepted socket. +/// TCP_NODELAY always (QW1 — Redis defaults `tcp-nodelay yes`; Nagle holds +/// pipelined responses for a delayed-ACK window otherwise), plus SO_KEEPALIVE +/// with TCP_KEEPIDLE (Linux) / TCP_KEEPALIVE (macOS) to detect dead +/// connections when `keepalive_secs > 0`. Called once per accepted socket. #[cfg(unix)] -fn set_tcp_keepalive(fd: std::os::unix::io::RawFd, keepalive_secs: u64) { - if keepalive_secs == 0 { - return; - } +fn apply_accepted_socket_opts(fd: std::os::unix::io::RawFd, keepalive_secs: u64) { use std::os::unix::io::BorrowedFd; // SAFETY: fd is a valid open socket owned by the caller. We borrow it // for the duration of this function — SockRef does not close on drop. let borrowed = unsafe { BorrowedFd::borrow_raw(fd) }; + let _ = crate::server::socket_opts::apply_client_socket_opts(&borrowed); + if keepalive_secs == 0 { + return; + } let sock = socket2::SockRef::from(&borrowed); let interval = std::cmp::max(keepalive_secs / 3, 1); let ka = socket2::TcpKeepalive::new() @@ -180,7 +183,7 @@ pub(crate) fn spawn_tokio_connection( { use std::os::unix::io::AsRawFd; let tcp_keepalive_secs = rtcfg.read().tcp_keepalive; - set_tcp_keepalive(tcp_stream.as_raw_fd(), tcp_keepalive_secs); + apply_accepted_socket_opts(tcp_stream.as_raw_fd(), tcp_keepalive_secs); } // Construct ConnectionContext from cloned shared state. The pool is @@ -485,7 +488,7 @@ pub(crate) fn spawn_monoio_connection( { use std::os::unix::io::AsRawFd; let keepalive_secs = runtime_config.read().tcp_keepalive; - set_tcp_keepalive(std_tcp_stream.as_raw_fd(), keepalive_secs); + apply_accepted_socket_opts(std_tcp_stream.as_raw_fd(), keepalive_secs); } match monoio::net::TcpStream::from_std(std_tcp_stream) { diff --git a/src/shard/event_loop.rs b/src/shard/event_loop.rs index 47155c124..5e9653d68 100644 --- a/src/shard/event_loop.rs +++ b/src/shard/event_loop.rs @@ -218,7 +218,7 @@ impl super::Shard { #[cfg(all(target_os = "linux", feature = "runtime-tokio"))] let mut inflight_sends: std::collections::HashMap< u32, - Vec<uring_handler::InFlightSend>, + std::collections::VecDeque<uring_handler::InFlightSend>, > = std::collections::HashMap::new(); // Per-shard SO_REUSEPORT listener (unix + tokio, non-uring path). @@ -664,6 +664,13 @@ impl super::Shard { }; let mut replica_txs: Vec<(u64, channel::MpscSender<bytes::Bytes>)> = Vec::new(); let repl_state: Option<Arc<std::sync::RwLock<ReplicationState>>> = repl_state_ext; + // QW3 (2026-06 review): lock-free offset handle cloned ONCE at shard + // startup. The SPSC drain's per-write offset advance goes through this + // handle, so the surrounding RwLock is never read-locked per write. + let repl_offsets: Option<crate::replication::state::OffsetHandle> = repl_state + .as_ref() + .and_then(|rs| rs.read().ok()) + .map(|g| g.offset_handle()); // Track last seen snapshot epoch to detect watch channel triggers let mut last_snapshot_epoch = snapshot_trigger_rx.borrow(); @@ -1046,10 +1053,14 @@ impl super::Shard { } } - // Pending wakers for monoio cross-shard write dispatch. - // monoio's !Send single-threaded executor doesn't see cross-thread Waker::wake() - // from flume oneshot channels. Connection tasks register their waker here; the - // event loop drains and wakes them after every SPSC processing cycle (~1ms). + // Waker relay, swept after every drain cycle. HISTORICAL NOTE: this was + // built on the belief that monoio's !Send executor cannot receive + // cross-thread Waker::wake() — that is FALSE with the `sync` feature + // (enabled in Cargo.toml): a remote wake rides a per-thread waker + // channel + driver unpark (eventfd/kqueue), proven at runtime by + // tests/spsc_wake_floor_red.rs::swf0. The hot cross-shard reply path + // now awaits its oneshot directly (handler_monoio, M2); the relay is + // kept for future registrants and stays correct either way. #[cfg(feature = "runtime-monoio")] let pending_wakers: Rc<RefCell<Vec<std::task::Waker>>> = Rc::new(RefCell::new(Vec::new())); @@ -1132,6 +1143,8 @@ impl super::Shard { match tcp_stream.into_std() { Ok(std_stream) => { use std::os::unix::io::IntoRawFd; + // QW1: nodelay before handing the fd to io_uring. + let _ = crate::server::socket_opts::apply_client_socket_opts(&std_stream); let raw_fd = std_stream.into_raw_fd(); match driver.register_connection(raw_fd) { Ok(Some(_conn_id)) => { @@ -1175,15 +1188,16 @@ impl super::Shard { } // SPSC notify -- event-driven cross-shard message drain _ = spsc_notify_local.notified() => { + crate::admin::metrics_setup::bump_spsc_notify_wake(); let mut pending_snapshot = None; // Phase 2d: gate on is_initialized(); new path uses ShardSlice directly. - if crate::shard::slice::is_initialized() { + let hit_cap = if crate::shard::slice::is_initialized() { crate::shard::slice::with_shard(|s| { spsc_handler::drain_spsc_shared( &shard_databases, &mut consumers, &mut *pubsub_arc.write(), &blocking_rc, &mut pending_snapshot, &mut snapshot_state, &mut wal_writer, &mut wal_v3_writer, &repl_backlog, &mut replica_txs, - &repl_state, shard_id, &script_cache_rc, &cached_clock, + &repl_offsets, shard_id, &script_cache_rc, &cached_clock, &mut pending_migrations, &mut s.vector_store, &mut pending_cdc_subscribes, &mut shard_manifest, @@ -1192,14 +1206,14 @@ impl super::Shard { server_config.graph_dead_edge_trigger, &mut autovacuum_daemon, aof_pool.as_ref(), // FIX-W1-2 - ); - }); + ) + }) } else { spsc_handler::drain_spsc_shared( &shard_databases, &mut consumers, &mut *pubsub_arc.write(), &blocking_rc, &mut pending_snapshot, &mut snapshot_state, &mut wal_writer, &mut wal_v3_writer, &repl_backlog, &mut replica_txs, - &repl_state, shard_id, &script_cache_rc, &cached_clock, + &repl_offsets, shard_id, &script_cache_rc, &cached_clock, &mut pending_migrations, &mut *shard_databases.vector_store(shard_id), &mut pending_cdc_subscribes, &mut shard_manifest, @@ -1208,7 +1222,13 @@ impl super::Shard { server_config.graph_dead_edge_trigger, &mut autovacuum_daemon, aof_pool.as_ref(), // FIX-W1-2 - ); + ) + }; + if hit_cap { + // M3: capped drain may have left a tail — re-arm immediately + // instead of stranding it until the next periodic tick. + spsc_notify_local.notify_one(); + crate::admin::metrics_setup::bump_spsc_drain_renotify(); } // MA5: persist maintenance schedule when modified by RECLAMATION SCHEDULE. if autovacuum_daemon.maintenance_schedule.is_dirty() { @@ -1285,13 +1305,13 @@ impl super::Shard { let mut pending_snapshot = None; // Phase 2d: gate on is_initialized(); new path uses ShardSlice directly. - if crate::shard::slice::is_initialized() { + let hit_cap = if crate::shard::slice::is_initialized() { crate::shard::slice::with_shard(|s| { spsc_handler::drain_spsc_shared( &shard_databases, &mut consumers, &mut *pubsub_arc.write(), &blocking_rc, &mut pending_snapshot, &mut snapshot_state, &mut wal_writer, &mut wal_v3_writer, &repl_backlog, &mut replica_txs, - &repl_state, shard_id, &script_cache_rc, &cached_clock, + &repl_offsets, shard_id, &script_cache_rc, &cached_clock, &mut pending_migrations, &mut s.vector_store, &mut pending_cdc_subscribes, &mut shard_manifest, @@ -1300,14 +1320,14 @@ impl super::Shard { server_config.graph_dead_edge_trigger, &mut autovacuum_daemon, aof_pool.as_ref(), // FIX-W1-2 - ); - }); + ) + }) } else { spsc_handler::drain_spsc_shared( &shard_databases, &mut consumers, &mut *pubsub_arc.write(), &blocking_rc, &mut pending_snapshot, &mut snapshot_state, &mut wal_writer, &mut wal_v3_writer, &repl_backlog, &mut replica_txs, - &repl_state, shard_id, &script_cache_rc, &cached_clock, + &repl_offsets, shard_id, &script_cache_rc, &cached_clock, &mut pending_migrations, &mut *shard_databases.vector_store(shard_id), &mut pending_cdc_subscribes, &mut shard_manifest, @@ -1316,7 +1336,13 @@ impl super::Shard { server_config.graph_dead_edge_trigger, &mut autovacuum_daemon, aof_pool.as_ref(), // FIX-W1-2 - ); + ) + }; + if hit_cap { + // M3: capped drain may have left a tail — re-arm immediately + // instead of stranding it until the next periodic tick. + spsc_notify_local.notify_one(); + crate::admin::metrics_setup::bump_spsc_drain_renotify(); } // MA5: persist maintenance schedule when modified by RECLAMATION SCHEDULE. if autovacuum_daemon.maintenance_schedule.is_dirty() { @@ -1908,18 +1934,34 @@ impl super::Shard { break; } - // Single await point — no select!, no per-iteration allocation. - // .0.tick() bypasses the RuntimeInterval trait's Box::pin() wrapper. - periodic_interval.0.tick().await; - monoio_tick_counter = monoio_tick_counter.wrapping_add(1); - - // --- Periodic tick body (same as tokio periodic_interval branch) --- - cached_clock.update(); - next_file_id = next_file_id.max(spill_file_id.get()); + // Single race await — still no monoio::select! (it leaks ~100 B per + // re-entry; hand-rolled race2 instead, M6) and no per-iteration + // allocation. .0.tick() bypasses the RuntimeInterval trait's + // Box::pin() wrapper. The timer arm drives the periodic body at its + // ~1ms cadence; the Notify arm (cross-shard producers + drain-cap + // self-re-notify) wakes the loop the moment a message arrives. + // monoio 0.2.4's `sync` feature carries the producer's cross-thread + // wake (per-thread waker channel + eventfd/kqueue unpark) — proven + // at runtime by tests/spsc_wake_floor_red.rs::swf0 on both drivers. + // A losing notified() arm re-queues an undelivered token on drop + // (swf_a3), so there is no lost-wake window. + let timer_fired = { + let tick = std::pin::pin!(periodic_interval.0.tick()); + let notified = std::pin::pin!(spsc_notify_local.notified()); + matches!( + crate::runtime::race::race2(tick, notified).await, + crate::runtime::race::Arm::First(_) + ) + }; + if !timer_fired { + crate::admin::metrics_setup::bump_spsc_notify_wake(); + } + // --- Every-wake body (mirrors the tokio notify arm): drain SPSC, + // handle drain outputs, sweep the pending_wakers relay --- let mut pending_snapshot = None; // Phase 2d: gate on is_initialized(); new path uses ShardSlice directly. - if crate::shard::slice::is_initialized() { + let hit_cap = if crate::shard::slice::is_initialized() { crate::shard::slice::with_shard(|s| { spsc_handler::drain_spsc_shared( &shard_databases, @@ -1932,7 +1974,7 @@ impl super::Shard { &mut wal_v3_writer, &repl_backlog, &mut replica_txs, - &repl_state, + &repl_offsets, shard_id, &script_cache_rc, &cached_clock, @@ -1945,8 +1987,8 @@ impl super::Shard { server_config.graph_dead_edge_trigger, &mut autovacuum_daemon, aof_pool.as_ref(), // FIX-W1-2 - ); - }); + ) + }) } else { spsc_handler::drain_spsc_shared( &shard_databases, @@ -1959,7 +2001,7 @@ impl super::Shard { &mut wal_v3_writer, &repl_backlog, &mut replica_txs, - &repl_state, + &repl_offsets, shard_id, &script_cache_rc, &cached_clock, @@ -1972,7 +2014,14 @@ impl super::Shard { server_config.graph_dead_edge_trigger, &mut autovacuum_daemon, aof_pool.as_ref(), // FIX-W1-2 - ); + ) + }; + if hit_cap { + // M3: the drain stopped at its per-cycle cap (or a snapshot + // barrier) — re-arm immediately so the tail drains on the next + // iteration instead of stranding until the next timer tick. + spsc_notify_local.notify_one(); + crate::admin::metrics_setup::bump_spsc_drain_renotify(); } if !pending_cdc_subscribes.is_empty() { let wal_dir = wal_v3_writer.as_ref().map(|w| w.wal_dir()); @@ -2047,6 +2096,16 @@ impl super::Shard { } } + // --- Periodic tick body (timer arm ONLY — M4: cadence work like + // WAL flush, sub-timers, and the cached clock must never run + // off-schedule on a notify wake) --- + if !timer_fired { + continue; + } + monoio_tick_counter = monoio_tick_counter.wrapping_add(1); + cached_clock.update(); + next_file_id = next_file_id.max(spill_file_id.get()); + persistence_tick::check_auto_save_trigger( &snapshot_trigger_rx, &mut last_snapshot_epoch, diff --git a/src/shard/shared_databases.rs b/src/shard/shared_databases.rs index 5f0259559..a32b34d57 100644 --- a/src/shard/shared_databases.rs +++ b/src/shard/shared_databases.rs @@ -31,8 +31,10 @@ pub struct ShardDatabases { graph_stores: Vec<RwLock<GraphStore>>, /// Per-shard WAL append channel sender. Connection handlers send serialized /// write commands here; the event loop drains into WAL v2/v3 on the 1ms tick. - /// Mutex<Option<>> for single-writer init, then read-only via wal_append(). - wal_append_txs: Vec<Mutex<Option<crate::runtime::channel::MpscSender<bytes::Bytes>>>>, + /// OnceLock: set once at event-loop startup (before connections are + /// accepted), then every hot-path read is lock-free (QW2, 2026-06 review + /// finding 1.8 — was Mutex<Option<>>, one lock acquire per write command). + wal_append_txs: Vec<std::sync::OnceLock<crate::runtime::channel::MpscSender<bytes::Bytes>>>, /// Per-shard TemporalRegistry for wall-clock-to-LSN bindings. /// Lazy-init: None until first TEMPORAL.SNAPSHOT_AT call. temporal_registries: Vec<Mutex<Option<Box<TemporalRegistry>>>>, @@ -96,7 +98,9 @@ impl ShardDatabases { let graph_stores = (0..num_shards) .map(|_| RwLock::new(GraphStore::new())) .collect(); - let wal_append_txs = (0..num_shards).map(|_| Mutex::new(None)).collect(); + let wal_append_txs = (0..num_shards) + .map(|_| std::sync::OnceLock::new()) + .collect(); let temporal_registries = (0..num_shards).map(|_| Mutex::new(None)).collect(); let temporal_kv_indexes = (0..num_shards).map(|_| Mutex::new(None)).collect(); let workspace_registries = (0..num_shards).map(|_| Mutex::new(None)).collect(); @@ -147,7 +151,12 @@ impl ShardDatabases { shard_id: usize, tx: crate::runtime::channel::MpscSender<bytes::Bytes>, ) { - *self.wal_append_txs[shard_id].lock() = Some(tx); + if self.wal_append_txs[shard_id].set(tx).is_err() { + tracing::warn!( + shard_id, + "wal_append_tx already initialized; re-init ignored (OnceLock)" + ); + } } /// Send serialized command bytes to the WAL append channel for a shard. @@ -157,7 +166,7 @@ impl ShardDatabases { /// No-op when persistence is disabled. #[inline] pub fn wal_append(&self, shard_id: usize, data: bytes::Bytes) { - if let Some(ref tx) = *self.wal_append_txs[shard_id].lock() { + if let Some(tx) = self.wal_append_txs[shard_id].get() { let _ = tx.try_send(data); } } @@ -171,8 +180,8 @@ impl ShardDatabases { #[inline] #[must_use = "callers must check the result and skip the mutation on WAL failure"] pub fn try_wal_append_required(&self, shard_id: usize, data: bytes::Bytes) -> bool { - match *self.wal_append_txs[shard_id].lock() { - Some(ref tx) => tx.try_send(data).is_ok(), + match self.wal_append_txs[shard_id].get() { + Some(tx) => tx.try_send(data).is_ok(), None => true, // persistence disabled — no durability requirement } } diff --git a/src/shard/spsc_handler.rs b/src/shard/spsc_handler.rs index f5c37ebc2..4051fc230 100644 --- a/src/shard/spsc_handler.rs +++ b/src/shard/spsc_handler.rs @@ -5,7 +5,7 @@ use std::cell::RefCell; use std::rc::Rc; -use std::sync::{Arc, RwLock}; +use std::sync::Arc; use ringbuf::HeapCons; use ringbuf::traits::Consumer; @@ -20,7 +20,6 @@ use crate::persistence::wal::WalWriter; use crate::persistence::wal_v3::segment::WalWriterV3; use crate::pubsub::PubSubRegistry; use crate::replication::backlog::ReplicationBacklog; -use crate::replication::state::ReplicationState; use crate::runtime::channel; use crate::storage::Database; use crate::storage::entry::CachedClock; @@ -36,6 +35,11 @@ use super::shared_databases::ShardDatabases; /// SnapshotBegin messages are collected into `pending_snapshot` for deferred handling /// (the caller has mutable access to snapshot_state). COW intercepts and WAL appends /// happen inline for Execute/MultiExecute write commands. +/// +/// Returns `true` when the cycle stopped early (MAX_DRAIN_PER_CYCLE cap or a +/// SnapshotBegin barrier) and queued messages may remain — the caller must +/// self-re-notify its own `spsc_notify` so the tail drains on the next loop +/// iteration instead of waiting for the periodic tick (spsc-wake-floor M3). #[tracing::instrument(skip_all, level = "debug")] pub(crate) fn drain_spsc_shared( shard_databases: &Arc<ShardDatabases>, @@ -52,7 +56,7 @@ pub(crate) fn drain_spsc_shared( wal_v3_writer: &mut Option<WalWriterV3>, repl_backlog: &crate::replication::backlog::SharedBacklog, replica_txs: &mut Vec<(u64, channel::MpscSender<bytes::Bytes>)>, - repl_state: &Option<Arc<RwLock<ReplicationState>>>, + repl_state: &Option<crate::replication::state::OffsetHandle>, shard_id: usize, script_cache: &Rc<RefCell<crate::scripting::ScriptCache>>, cached_clock: &CachedClock, @@ -74,7 +78,7 @@ pub(crate) fn drain_spsc_shared( // FIX-W1-2: per-shard AOF writer pool. Passed through to handle_shard_message_shared // so cross-shard writes (MSET/MultiExecute) also land in the per-shard AOF files. aof_pool: Option<&std::sync::Arc<crate::persistence::aof::AofWriterPool>>, -) { +) -> bool { const MAX_DRAIN_PER_CYCLE: usize = 256; let mut drained = 0; @@ -231,6 +235,12 @@ pub(crate) fn drain_spsc_shared( DRAIN_SCRATCH.with(|s| { *s.borrow_mut() = (execute_batch, other_messages); }); + + // spsc-wake-floor M3: `true` means this cycle stopped early (drain cap or + // SnapshotBegin barrier) and messages may remain queued — the caller must + // self-re-notify so the tail is drained on the next loop iteration instead + // of stranding until the next periodic tick. + drained >= MAX_DRAIN_PER_CYCLE || snapshot_seen } /// Process a single cross-shard message using shared database access. @@ -252,7 +262,7 @@ pub(crate) fn handle_shard_message_shared( wal_v3_writer: &mut Option<WalWriterV3>, repl_backlog: &crate::replication::backlog::SharedBacklog, replica_txs: &mut Vec<(u64, channel::MpscSender<bytes::Bytes>)>, - repl_state: &Option<Arc<RwLock<ReplicationState>>>, + repl_state: &Option<crate::replication::state::OffsetHandle>, shard_id: usize, script_cache: &Rc<RefCell<crate::scripting::ScriptCache>>, cached_clock: &CachedClock, @@ -3042,7 +3052,7 @@ pub(crate) fn wal_append_and_fanout( wal_v3_writer: &mut Option<WalWriterV3>, repl_backlog: &crate::replication::backlog::SharedBacklog, replica_txs: &[(u64, channel::MpscSender<bytes::Bytes>)], - repl_state: &Option<Arc<RwLock<ReplicationState>>>, + repl_state: &Option<crate::replication::state::OffsetHandle>, shard_id: usize, aof_pool: Option<&std::sync::Arc<crate::persistence::aof::AofWriterPool>>, ) { @@ -3086,11 +3096,11 @@ pub(crate) fn wal_append_and_fanout( } drop(guard); // 3. Advance monotonic replication offset (NEVER resets on WAL truncation) - if let Some(rs) = repl_state { - match rs.read() { - Ok(rs) => rs.increment_shard_offset(shard_id, data.len() as u64), - Err(_) => tracing::error!("repl_state lock poisoned, replication offset not updated"), - } + // QW3 (2026-06 review finding 1.4): `repl_state` is a lock-free + // OffsetHandle cloned out of `RwLock<ReplicationState>` once at shard + // startup — the per-write advance no longer read-locks the RwLock. + if let Some(offsets) = repl_state { + offsets.increment_shard_offset(shard_id, data.len() as u64); } // 4. Fan-out to replica sender tasks (non-blocking: lagging replicas are skipped) if !replica_txs.is_empty() { @@ -3314,3 +3324,115 @@ mod wal_append_tests { ); } } + +#[cfg(test)] +mod drain_cap_tests { + use super::*; + use ringbuf::HeapRb; + use ringbuf::traits::{Producer, Split}; + + /// M3 (spsc-wake-floor): a drain cycle that stops at MAX_DRAIN_PER_CYCLE + /// (256) must return `true` — queued messages may remain, so the caller + /// self-re-notifies — while a cycle that empties the rings returns + /// `false`. The integration suite cannot reach the cap from one client + /// (pipelined commands coalesce into one PipelineBatch per target per + /// read chunk), so the cap path is pinned here with 300 real ring + /// messages. `BlockCancel` for an unknown wait_id is a harmless no-op, + /// which keeps every other dependency inert (no WAL, no snapshot). + #[test] + fn drain_cap_reports_possible_tail() { + let shard_databases = Arc::new(ShardDatabases::new(vec![vec![Database::new()]])); + let rb = HeapRb::<ShardMessage>::new(512); + let (mut prod, cons) = rb.split(); + for i in 0..300u64 { + assert!( + prod.try_push(ShardMessage::BlockCancel { wait_id: i }) + .is_ok(), + "ring accepts 300 messages" + ); + } + let mut consumers = vec![cons]; + + let mut pubsub = PubSubRegistry::new(); + let blocking = Rc::new(RefCell::new(BlockingRegistry::new(0))); + let mut pending_snapshot = None; + let mut snapshot_state: Option<SnapshotState> = None; + let mut wal_writer: Option<WalWriter> = None; + let mut wal_v3_writer: Option<WalWriterV3> = None; + let backlog: crate::replication::backlog::SharedBacklog = + Arc::new(parking_lot::Mutex::new(None)); + let mut replica_txs = Vec::new(); + let offsets: Option<crate::replication::state::OffsetHandle> = None; + let script_cache = Rc::new(RefCell::new(crate::scripting::ScriptCache::new())); + let clock = CachedClock::new(); + let mut migrations = Vec::new(); + let mut vector_store = VectorStore::new(); + let mut cdc = Vec::new(); + let mut manifest = None; + let mut autovacuum = crate::shard::autovacuum::AutovacuumDaemon::new(Default::default()); + + // First cycle: 300 queued > 256 cap -> drains exactly 256, reports tail. + let hit_cap = drain_spsc_shared( + &shard_databases, + &mut consumers, + &mut pubsub, + &blocking, + &mut pending_snapshot, + &mut snapshot_state, + &mut wal_writer, + &mut wal_v3_writer, + &backlog, + &mut replica_txs, + &offsets, + 0, + &script_cache, + &clock, + &mut migrations, + &mut vector_store, + &mut cdc, + &mut manifest, + 1000, + 8, + 0.2, + &mut autovacuum, + None, + ); + assert!( + hit_cap, + "300 queued messages exceed the 256 cap: first cycle must report a possible tail" + ); + + // Second cycle: the 44 remaining messages drain fully -> no tail. + let hit_cap2 = drain_spsc_shared( + &shard_databases, + &mut consumers, + &mut pubsub, + &blocking, + &mut pending_snapshot, + &mut snapshot_state, + &mut wal_writer, + &mut wal_v3_writer, + &backlog, + &mut replica_txs, + &offsets, + 0, + &script_cache, + &clock, + &mut migrations, + &mut vector_store, + &mut cdc, + &mut manifest, + 1000, + 8, + 0.2, + &mut autovacuum, + None, + ); + assert!( + !hit_cap2, + "44 remaining messages drain fully: second cycle must report no tail" + ); + use ringbuf::traits::Observer; + assert!(consumers[0].is_empty(), "all 300 messages must be consumed"); + } +} diff --git a/src/shard/uring_handler.rs b/src/shard/uring_handler.rs index d46b459c4..06593bde9 100644 --- a/src/shard/uring_handler.rs +++ b/src/shard/uring_handler.rs @@ -45,7 +45,7 @@ pub(crate) fn send_serialized( driver: &mut UringDriver, conn_id: u32, resp_buf: bytes::BytesMut, - inflight_sends: &mut std::collections::HashMap<u32, Vec<InFlightSend>>, + inflight_sends: &mut std::collections::HashMap<u32, std::collections::VecDeque<InFlightSend>>, ) { let resp_len = resp_buf.len(); // Try pooled fixed buffer: must fit in pool buffer size @@ -56,7 +56,7 @@ pub(crate) fn send_serialized( inflight_sends .entry(conn_id) .or_default() - .push(InFlightSend::Fixed(buf_idx)); + .push_back(InFlightSend::Fixed(buf_idx)); return; } // Response too large for pooled buffer -- reclaim and fall through to heap @@ -69,7 +69,7 @@ pub(crate) fn send_serialized( inflight_sends .entry(conn_id) .or_default() - .push(InFlightSend::Buf(resp_buf)); + .push_back(InFlightSend::Buf(resp_buf)); } /// Handles recv (parse RESP frames + execute commands + send responses), @@ -83,7 +83,7 @@ pub(crate) fn handle_uring_event( shard_databases: &Arc<ShardDatabases>, shard_id: usize, parse_bufs: &mut std::collections::HashMap<u32, bytes::BytesMut>, - inflight_sends: &mut std::collections::HashMap<u32, Vec<InFlightSend>>, + inflight_sends: &mut std::collections::HashMap<u32, std::collections::VecDeque<InFlightSend>>, uring_listener_fd: Option<std::os::fd::RawFd>, cached_clock: &CachedClock, ) { @@ -269,7 +269,7 @@ pub(crate) fn handle_uring_event( inflight_sends .entry(conn_id) .or_default() - .push(InFlightSend::Writev(guard)); + .push_back(InFlightSend::Writev(guard)); } Err(e) => { tracing::warn!( @@ -290,7 +290,7 @@ pub(crate) fn handle_uring_event( inflight_sends .entry(conn_id) .or_default() - .push(InFlightSend::Writev(guard)); + .push_back(InFlightSend::Writev(guard)); } Err(e) => { tracing::warn!( @@ -342,11 +342,10 @@ pub(crate) fn handle_uring_event( IoEvent::SendComplete { conn_id } => { // Drop the oldest in-flight send buffer (FIFO order matches CQE order). if let Some(sends) = inflight_sends.get_mut(&conn_id) { - if !sends.is_empty() { - let send = sends.remove(0); - if let InFlightSend::Fixed(idx) = send { - driver.reclaim_send_buf(idx); - } + // QW6 (2026-06 review finding 3.6): VecDeque pop_front is + // O(1); Vec::remove(0) shifted the whole tail per CQE. + if let Some(InFlightSend::Fixed(idx)) = sends.pop_front() { + driver.reclaim_send_buf(idx); } if sends.is_empty() { inflight_sends.remove(&conn_id); @@ -380,5 +379,13 @@ pub(crate) fn handle_uring_event( pub(crate) fn create_reuseport_listener(addr: &str) -> std::io::Result<std::os::fd::RawFd> { use std::os::unix::io::IntoRawFd; let std_listener = super::conn_accept::create_reuseport_socket(addr)?; + // QW1: TCP_NODELAY on the LISTENING socket. Multishot-accept CQEs deliver + // raw fds with no safe per-socket hook; Linux copies the nonagle flag to + // sockets returned by accept(2), so setting it here covers them. + if let Err(e) = crate::server::socket_opts::apply_client_socket_opts(&std_listener) { + // Non-fatal: the listener still works, but accepted sockets keep Nagle + // (pipelined replies may see delayed-ACK latency). Surface it loudly. + tracing::warn!("failed to set TCP_NODELAY on uring listener: {e}"); + } Ok(std_listener.into_raw_fd()) } diff --git a/src/vector/store.rs b/src/vector/store.rs index 8a3f795cf..c0ab802a8 100644 --- a/src/vector/store.rs +++ b/src/vector/store.rs @@ -1399,7 +1399,7 @@ impl VectorStore { let key_hash = xxhash_rust::xxh64::xxh64(key, 0); let mut any_deleted = false; for idx_name in matching_names { - if let Some(idx) = self.indexes.get(&idx_name) { + if let Some(idx) = self.indexes.get_mut(&idx_name) { let snap = idx.segments.load(); // Tombstone in mutable segment (always present). snap.mutable.mark_deleted_by_key_hash(key_hash, 1); @@ -1408,6 +1408,12 @@ impl VectorStore { for imm in snap.immutable.iter() { imm.mark_deleted_by_key_hash(key_hash); } + // QW7 (2026-06 review finding 6.3): prune the key-hash maps so + // they track LIVE keys, not historical inserts — without this + // they grow monotonically under key churn (~1GB / 24M deletes). + // A re-insert of the same key repopulates both maps. + idx.key_hash_to_key.remove(&key_hash); + idx.key_hash_to_global_id.remove(&key_hash); any_deleted = true; } } diff --git a/tests/quickwins_red.rs b/tests/quickwins_red.rs new file mode 100644 index 000000000..4caea5262 --- /dev/null +++ b/tests/quickwins_red.rs @@ -0,0 +1,322 @@ +//! ADD task `hotpath-lock-quickwins` — failing-first suite (runtime tests). +//! +//! RED tests (fail until the build lands): +//! - qw7_vector_key_maps_pruned_on_delete — key-map pruning does not exist yet. +//! +//! PIN tests (green before AND after — the parity safety net the batch must +//! not break; Reject: "behavior_regression"): +//! - qw5_backlog_append_golden — byte-identical backlog state machine. +//! - qw2_try_wal_append_required_false_on_full — durability gate contract. +//! - qw3_replication_offsets_exact — offset arithmetic under concurrency. +//! - qw4_total_commands_exact — bespoke INFO counter exactness. +//! +//! New-API red tests (compile-red) live in `tests/quickwins_red_api.rs`. +//! Run this file alone with: cargo test --test quickwins_red + +use bytes::Bytes; +use moon::replication::backlog::ReplicationBacklog; +use moon::replication::state::ReplicationState; + +// --------------------------------------------------------------------------- +// QW7 — vector key maps shrink on delete (RED until pruning is implemented) +// --------------------------------------------------------------------------- + +mod qw7 { + use super::*; + use moon::vector::distance; + use moon::vector::store::{IndexMeta, MergeMode, VectorStore}; + use moon::vector::turbo_quant::collection::QuantizationConfig; + use moon::vector::turbo_quant::encoder::padded_dimension; + use moon::vector::types::DistanceMetric; + + fn make_idx(dim: u32) -> IndexMeta { + IndexMeta { + name: Bytes::from_static(b"idx"), + dimension: dim, + padded_dimension: padded_dimension(dim), + metric: DistanceMetric::L2, + hnsw_m: 8, + hnsw_ef_construction: 50, + hnsw_ef_runtime: 0, + compact_threshold: 0, + source_field: Bytes::from_static(b"vec"), + key_prefixes: vec![Bytes::from_static(b"doc:")], + quantization: QuantizationConfig::TurboQuant4, + build_mode: moon::vector::turbo_quant::collection::BuildMode::Light, + vector_fields: Vec::new(), + schema_fields: Vec::new(), + merge_mode: MergeMode::GraphUnion, + keep_raw: false, + } + } + + fn det_vec(dim: usize, seed: u64) -> Vec<f32> { + // Deterministic pseudo-vector; distribution quality is irrelevant here. + (0..dim) + .map(|i| { + let x = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(i as u64); + ((x >> 33) as f32) / (u32::MAX as f32) + }) + .collect() + } + + /// Deleting indexed vectors must shrink the per-index key-hash maps — + /// they track LIVE keys, not historical inserts (review finding 6.3: + /// today there is no `.remove()` call anywhere; the maps grow forever). + #[test] + fn qw7_vector_key_maps_pruned_on_delete() { + distance::init(); + let mut store = VectorStore::new(); + store.create_index(make_idx(64)).unwrap(); + + const TOTAL: usize = 100; + const DELETED: usize = 60; + + for i in 0..TOTAL { + let key = format!("doc:{i}"); + let hash = xxhash_rust::xxh64::xxh64(key.as_bytes(), 0); + store + .insert_vector(b"idx", &det_vec(64, i as u64), hash, Bytes::from(key)) + .unwrap(); + } + assert_eq!( + store.get_index(b"idx").unwrap().key_hash_to_key.len(), + TOTAL, + "sanity: insert populates key_hash_to_key" + ); + + for i in 0..DELETED { + let key = format!("doc:{i}"); + store.mark_deleted_for_key(key.as_bytes()); + } + + let idx = store.get_index(b"idx").unwrap(); + assert_eq!( + idx.key_hash_to_key.len(), + TOTAL - DELETED, + "key_hash_to_key must track live keys only (RED until QW7 pruning lands)" + ); + let deleted_hash = xxhash_rust::xxh64::xxh64(b"doc:0", 0); + assert!( + !idx.key_hash_to_key.contains_key(&deleted_hash), + "deleted key's hash entry must be pruned" + ); + assert!( + !idx.key_hash_to_global_id.contains_key(&deleted_hash), + "deleted key's global-id entry must be pruned" + ); + } +} + +// --------------------------------------------------------------------------- +// QW5 — backlog append golden model (PIN: byte-identical before/after) +// --------------------------------------------------------------------------- + +/// Reference model: the current per-byte semantics, kept in the test so the +/// bulk-copy rewrite can be checked against it step by step. +struct ModelBacklog { + buf: std::collections::VecDeque<u8>, + capacity: usize, + start_offset: u64, + end_offset: u64, +} + +impl ModelBacklog { + fn new(capacity: usize) -> Self { + ModelBacklog { + buf: std::collections::VecDeque::with_capacity(capacity), + capacity, + start_offset: 0, + end_offset: 0, + } + } + fn append(&mut self, data: &[u8]) { + for &b in data { + if self.buf.len() == self.capacity { + self.buf.pop_front(); + self.start_offset += 1; + } + self.buf.push_back(b); + } + self.end_offset += data.len() as u64; + } +} + +#[test] +fn qw5_backlog_append_golden() { + const CAP: usize = 64; + let mut real = ReplicationBacklog::new(CAP); + let mut model = ModelBacklog::new(CAP); + + let sizes = [1usize, CAP / 2, CAP, CAP + 7, 3 * CAP, 0, 5]; + let mut byte: u8 = 0; + for (step, &n) in sizes.iter().enumerate() { + let chunk: Vec<u8> = (0..n) + .map(|_| { + byte = byte.wrapping_add(13); + byte + }) + .collect(); + real.append(&chunk); + model.append(&chunk); + + assert_eq!( + real.start_offset(), + model.start_offset, + "step {step}: start_offset diverged" + ); + assert_eq!( + real.end_offset(), + model.end_offset, + "step {step}: end_offset diverged" + ); + let got = real + .bytes_from(model.start_offset) + .expect("start_offset must be readable"); + let want: Vec<u8> = model.buf.iter().copied().collect(); + assert_eq!(got, want, "step {step}: buffer contents diverged"); + } + + // Range probes across the live window. + let (lo, hi) = (real.start_offset(), real.end_offset()); + for off in [lo, lo + 1, (lo + hi) / 2, hi] { + let got = real.bytes_from(off); + let want = if off >= model.start_offset && off <= model.end_offset { + Some( + model + .buf + .iter() + .copied() + .skip((off - model.start_offset) as usize) + .collect::<Vec<u8>>(), + ) + } else { + None + }; + assert_eq!(got, want, "bytes_from({off}) diverged"); + } + // Evicted and future offsets stay rejected. + assert_eq!(real.bytes_from(lo.wrapping_sub(1)), None, "evicted offset"); + assert_eq!(real.bytes_from(hi + 1), None, "future offset"); +} + +// --------------------------------------------------------------------------- +// QW2 — WAL required-append gates mutation on a full channel (PIN) +// --------------------------------------------------------------------------- + +#[test] +fn qw2_try_wal_append_required_false_on_full() { + use moon::shard::shared_databases::ShardDatabases; + use moon::storage::db::Database; + + let dbs = ShardDatabases::new(vec![vec![Database::new()]]); + + // Persistence disabled -> no durability requirement -> true. + assert!( + dbs.try_wal_append_required(0, Bytes::from_static(b"x")), + "no WAL configured: append must report success (no durability requirement)" + ); + + // Bounded(1) channel, never drained: first append fills it, second must + // be refused — the caller-visible gate that prevents un-journaled mutation + // (Reject: durability_contract_broken). + let (tx, _rx) = moon::runtime::channel::mpsc_bounded::<Bytes>(1); + dbs.set_wal_append_tx(0, tx); + + assert!( + dbs.try_wal_append_required(0, Bytes::from_static(b"a")), + "first append fits the bounded(1) channel" + ); + assert!( + !dbs.try_wal_append_required(0, Bytes::from_static(b"b")), + "full WAL channel must report false so the caller skips the mutation" + ); + + // Fire-and-forget variant must not panic on a full channel. + dbs.wal_append(0, Bytes::from_static(b"c")); +} + +// --------------------------------------------------------------------------- +// QW3 — replication offset arithmetic exact under concurrency (PIN) +// --------------------------------------------------------------------------- + +#[test] +fn qw3_replication_offsets_exact() { + use std::sync::Arc; + + const SHARDS: usize = 4; + const WRITES_PER_SHARD: u64 = 1_000; + const DELTA: u64 = 17; + + let state = Arc::new(ReplicationState::new( + SHARDS, + "a".repeat(40), + "b".repeat(40), + )); + + let handles: Vec<_> = (0..SHARDS) + .map(|shard| { + let st = Arc::clone(&state); + std::thread::spawn(move || { + for _ in 0..WRITES_PER_SHARD { + st.increment_shard_offset(shard, DELTA); + } + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + + for shard in 0..SHARDS { + assert_eq!( + state.shard_offset(shard), + WRITES_PER_SHARD * DELTA, + "shard {shard} offset must equal exact byte total" + ); + } + assert_eq!( + state.total_offset(), + SHARDS as u64 * WRITES_PER_SHARD * DELTA, + "master_repl_offset must equal the sum of all shard writes" + ); +} + +// --------------------------------------------------------------------------- +// QW4 — bespoke INFO command counter exact under concurrency (PIN) +// --------------------------------------------------------------------------- + +#[test] +fn qw4_total_commands_exact() { + use moon::admin::metrics_setup; + + const THREADS: usize = 4; + const PER_THREAD: u64 = 5_000; + + let before = metrics_setup::total_commands_processed(); + let handles: Vec<_> = (0..THREADS) + .map(|_| { + std::thread::spawn(|| { + for i in 0..PER_THREAD { + if i % 2 == 0 { + metrics_setup::record_command_no_latency("set"); + } else { + metrics_setup::record_command("get", 1); + } + } + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + let after = metrics_setup::total_commands_processed(); + + assert_eq!( + after - before, + THREADS as u64 * PER_THREAD, + "INFO total_commands_processed must stay exact (no lost or double counts)" + ); +} diff --git a/tests/quickwins_red_api.rs b/tests/quickwins_red_api.rs new file mode 100644 index 000000000..f3007c55e --- /dev/null +++ b/tests/quickwins_red_api.rs @@ -0,0 +1,60 @@ +//! ADD task `hotpath-lock-quickwins` — new-API red tests. +//! +//! This file is COMPILE-RED until the build lands: it references the small +//! public surfaces the contract introduces. Each test is behavior-level once +//! the symbol exists. Kept separate from `quickwins_red.rs` so the runtime +//! suite stays runnable during the red phase: +//! cargo test --test quickwins_red # runs (1 red, 4 pins) +//! cargo test --test quickwins_red_api # compile error = red, by design + +use std::net::{TcpListener, TcpStream}; + +// --------------------------------------------------------------------------- +// QW1 — every accepted client socket gets TCP_NODELAY (+ shared accept opts) +// --------------------------------------------------------------------------- + +/// The accept paths (tokio + uring register) funnel socket options through one +/// helper; applying it to an accepted fd must enable TCP_NODELAY. +#[test] +fn qw1_accepted_socket_has_nodelay() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let _client = TcpStream::connect(addr).unwrap(); + let (accepted, _) = listener.accept().unwrap(); + + // Fresh accepted socket: Nagle is on by default (sanity). + assert!( + !accepted.nodelay().unwrap(), + "sanity: OS default leaves Nagle enabled" + ); + + moon::server::socket_opts::apply_client_socket_opts(&accepted) + .expect("applying client socket opts must succeed"); + + assert!( + accepted.nodelay().unwrap(), + "QW1: accepted client sockets must have TCP_NODELAY set" + ); +} + +// --------------------------------------------------------------------------- +// QW3 — replication offsets reachable WITHOUT locking ReplicationState +// --------------------------------------------------------------------------- + +/// The write path receives an `OffsetHandle` at startup and advances offsets +/// through it; the `RwLock<ReplicationState>` is no longer touched per write. +/// Handle and state must observe the same totals. +#[test] +fn qw3_offset_handle_advances_without_state_lock() { + use moon::replication::state::ReplicationState; + + let state = ReplicationState::new(2, "a".repeat(40), "b".repeat(40)); + let handle = state.offset_handle(); + + handle.issue_lsn(0, 100); + handle.issue_lsn(1, 50); + + assert_eq!(state.shard_offset(0), 100); + assert_eq!(state.shard_offset(1), 50); + assert_eq!(state.total_offset(), 150); +} diff --git a/tests/spsc_wake_floor_red.rs b/tests/spsc_wake_floor_red.rs new file mode 100644 index 000000000..cd5a2205e --- /dev/null +++ b/tests/spsc_wake_floor_red.rs @@ -0,0 +1,588 @@ +//! ADD task `spsc-wake-floor` — failing-first suite (runtime tests). +//! +//! RED tests (fail until the build lands — the INFO fields do not exist yet): +//! - swf1_notify_wakes_counter — INFO `spsc_notify_wakes` present and > 0. +//! - swf2_burst_renotify — INFO `spsc_drain_renotify` present and ≥ 1. +//! +//! PIN / assumption tests (must be green BEFORE build — they settle the frozen +//! contract's flagged assumptions; a failure here triggers the pre-agreed +//! fallback BEFORE any build work): +//! - swf0_monoio_cross_thread_wake — A1: monoio 0.2.4 `sync` cross-thread +//! Waker::wake() reaches a parked monoio task (FusionDriver: io_uring on +//! Linux, kqueue on macOS). The codebase's relay comments claim this is +//! impossible; this test is the runtime proof either way. +//! - swf_a3_notify_token_survives_poll_drop — A3: a notify token delivered to +//! a registered-then-dropped `notified()` future is NOT lost (flume re-queues). +//! - swf3_cadence_pins — M4 guard: cached-clock expiry and WAL +//! flush cadence behave the same before and after the build. +//! +//! New-API compile-red tests (race2, counters) live in `spsc_wake_floor_red_api.rs`. +//! Run this file alone with: cargo test --test spsc_wake_floor_red + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::process::{Child, Command}; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// Shared harness (CARGO_BIN_EXE pattern, mirrors multishard_serve_smoke.rs) +// --------------------------------------------------------------------------- + +fn moon_binary() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_BIN_EXE_moon")) +} + +fn free_port() -> u16 { + let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind :0"); + let p = l.local_addr().expect("local_addr").port(); + drop(l); + p +} + +/// Fresh `--dir` per server (CWD persistence-reload trap: an inherited +/// appendonlydir would replay stale state into a throwaway test server). +fn spawn_moon(port: u16, dir: &std::path::Path, extra: &[&str]) -> Child { + spawn_moon_with_shards(port, dir, 2, extra) +} + +fn spawn_moon_with_shards(port: u16, dir: &std::path::Path, shards: u32, extra: &[&str]) -> Child { + let mut args: Vec<String> = vec![ + "--port".into(), + port.to_string(), + "--dir".into(), + dir.to_string_lossy().into_owned(), + "--shards".into(), + shards.to_string(), + ]; + for e in extra { + args.push((*e).into()); + } + Command::new(moon_binary()) + .args(&args) + .stdout(std::fs::File::create(dir.join("moon.stdout.log")).expect("stdout log")) + .stderr(std::fs::File::create(dir.join("moon.stderr.log")).expect("stderr log")) + .spawn() + .expect("spawn moon (CARGO_BIN_EXE_moon)") +} + +fn connect(port: u16, deadline: Duration) -> TcpStream { + let addr = format!("127.0.0.1:{port}") + .to_socket_addrs() + .expect("addr") + .next() + .expect("one addr"); + let start = Instant::now(); + loop { + match TcpStream::connect_timeout(&addr, Duration::from_millis(200)) { + Ok(s) => { + s.set_read_timeout(Some(Duration::from_secs(5))).ok(); + s.set_write_timeout(Some(Duration::from_secs(5))).ok(); + return s; + } + Err(_) if start.elapsed() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => panic!("server never accepted on {port}: {e}"), + } + } +} + +/// Wait for the server to answer PING (started AND serving). +/// Generous connect deadline: the FIRST exec of a freshly-linked binary on +/// macOS can stall >10s in code-signature validation (dyld/amfi), especially +/// with several test servers spawning concurrently after a rebuild. +fn wait_ready(port: u16) -> TcpStream { + let mut s = connect(port, Duration::from_secs(30)); + let start = Instant::now(); + loop { + s.write_all(b"PING\r\n").expect("write PING"); + let mut buf = [0u8; 64]; + if let Ok(n) = s.read(&mut buf) { + if n > 0 && buf[..n].windows(4).any(|w| w == b"PONG") { + return s; + } + } + assert!( + start.elapsed() < Duration::from_secs(10), + "server accepted TCP but never answered PING" + ); + std::thread::sleep(Duration::from_millis(100)); + s = connect(port, Duration::from_secs(5)); + } +} + +/// Send one RESP command, return the raw reply bytes (single read may suffice +/// for short replies; loops until at least `min_len` bytes or CRLF-terminated). +fn resp_cmd(s: &mut TcpStream, parts: &[&[u8]]) -> Vec<u8> { + let mut req = Vec::with_capacity(64); + req.extend_from_slice(format!("*{}\r\n", parts.len()).as_bytes()); + for p in parts { + req.extend_from_slice(format!("${}\r\n", p.len()).as_bytes()); + req.extend_from_slice(p); + req.extend_from_slice(b"\r\n"); + } + s.write_all(&req).expect("write cmd"); + read_reply(s) +} + +/// Read one reply frame (simple string / error / integer / bulk / null). +fn read_reply(s: &mut TcpStream) -> Vec<u8> { + let mut buf = vec![0u8; 64 * 1024]; + let n = s.read(&mut buf).expect("read reply"); + assert!(n > 0, "connection closed mid-reply"); + buf.truncate(n); + // Bulk strings may need a second read if header and body split. + if buf[0] == b'$' && !buf.ends_with(b"\r\n") { + let mut more = vec![0u8; 64 * 1024]; + if let Ok(m) = s.read(&mut more) { + buf.extend_from_slice(&more[..m]); + } + } + buf +} + +/// Fetch INFO and return it as a lossy string. +fn info(s: &mut TcpStream) -> String { + // INFO replies are large; read until the bulk length is satisfied. + s.write_all(b"*1\r\n$4\r\nINFO\r\n").expect("write INFO"); + let mut acc: Vec<u8> = Vec::with_capacity(16 * 1024); + let mut buf = vec![0u8; 64 * 1024]; + let deadline = Instant::now() + Duration::from_secs(5); + let mut expected_total: Option<usize> = None; + loop { + let n = s.read(&mut buf).expect("read INFO"); + assert!(n > 0, "connection closed during INFO"); + acc.extend_from_slice(&buf[..n]); + if expected_total.is_none() { + // Parse "$<len>\r\n" header once available. + if let Some(pos) = acc.windows(2).position(|w| w == b"\r\n") { + assert_eq!(acc[0], b'$', "INFO must be a bulk string: {acc:?}"); + let len: usize = std::str::from_utf8(&acc[1..pos]) + .expect("utf8 len") + .parse() + .expect("bulk len"); + expected_total = Some(pos + 2 + len + 2); + } + } + if let Some(t) = expected_total { + if acc.len() >= t { + break; + } + } + assert!(Instant::now() < deadline, "INFO read timed out"); + } + String::from_utf8_lossy(&acc).into_owned() +} + +/// Extract `field:<u64>` from an INFO payload. +fn info_u64(payload: &str, field: &str) -> Option<u64> { + payload.lines().find_map(|l| { + let l = l.trim_end_matches('\r'); + l.strip_prefix(&format!("{field}:")) + .and_then(|v| v.parse::<u64>().ok()) + }) +} + +struct ServerGuard(Child); +impl Drop for ServerGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +// --------------------------------------------------------------------------- +// swf0 — A1 assumption pin: monoio `sync` cross-thread wake (runs FIRST) +// --------------------------------------------------------------------------- + +/// monoio 0.2.4 with the `sync` feature (enabled in Cargo.toml) routes a remote +/// `Waker::wake()` through a per-thread waker channel + driver unpark (eventfd +/// on io_uring, kqueue-wake on the legacy driver). The pending_wakers relay was +/// built on the belief this does NOT work. This test settles it at runtime: +/// a parked monoio task awaiting moon's `Notify` (flume bounded(1)) and a flume +/// oneshot must be woken by a plain std thread well before a 500ms timer. +/// +/// GREEN -> A1 holds; build proceeds on the frozen mechanism. +/// FAIL -> A1 disproved; invoke the §3 pre-agreed fallback (notify-origin) +/// BEFORE build. Do not "fix" this test. +#[cfg(feature = "runtime-monoio")] +#[test] +fn swf0_monoio_cross_thread_wake() { + use std::sync::Arc; + + let notify = Arc::new(moon::runtime::channel::Notify::new()); + let (oneshot_tx, oneshot_rx) = flume::bounded::<u8>(1); + + let n2 = Arc::clone(¬ify); + let waker_thread = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(5)); + n2.notify_one(); + std::thread::sleep(Duration::from_millis(5)); + let _ = oneshot_tx.send(42); + }); + + // Same runtime construction as production (runtime/monoio_impl.rs). + let mut rt = monoio::RuntimeBuilder::<monoio::FusionDriver>::new() + .enable_timer() + .build() + .expect("build monoio runtime"); + + let (notify_elapsed, oneshot_elapsed) = rt.block_on(async move { + // Wake target is a SPAWNED (!Send, local) task — same shape as a + // production connection task, not the block_on root. + let (done_tx, done_rx) = flume::bounded::<(Duration, Duration)>(1); + monoio::spawn(async move { + let t0 = Instant::now(); + let woke_notify = monoio::time::timeout(Duration::from_millis(500), async { + notify.notified().await; + }) + .await + .is_ok(); + let notify_elapsed = t0.elapsed(); + assert!( + woke_notify, + "Notify::notified() never woken cross-thread (A1 disproved for Notify)" + ); + + let t1 = Instant::now(); + let got = monoio::time::timeout(Duration::from_millis(500), async { + oneshot_rx.recv_async().await + }) + .await; + let oneshot_elapsed = t1.elapsed(); + assert!( + matches!(got, Ok(Ok(42))), + "flume recv_async never woken cross-thread (A1 disproved for oneshot): {got:?}" + ); + let _ = done_tx.send((notify_elapsed, oneshot_elapsed)); + }); + done_rx + .recv_async() + .await + .expect("spawned waiter task must report") + }); + + waker_thread.join().expect("waker thread"); + + // The 500ms timeout itself would wake the runtime, so distinguish a REAL + // cross-thread wake (few ms) from a timer-driven one (~500ms). + assert!( + notify_elapsed < Duration::from_millis(100), + "Notify wake took {notify_elapsed:?} — timer-driven, not event-driven (A1 disproved)" + ); + assert!( + oneshot_elapsed < Duration::from_millis(100), + "oneshot wake took {oneshot_elapsed:?} — timer-driven, not event-driven (A1 disproved)" + ); +} + +// --------------------------------------------------------------------------- +// swf_a3 — A3 assumption pin: notify token survives a dropped RecvFut +// --------------------------------------------------------------------------- + +/// In the race design the timer arm can win while `notified()` is mid-flight: +/// the future was polled (waker hook registered), the token arrives, then the +/// future is DROPPED without completing. flume must re-queue the undelivered +/// token so the NEXT `notified()` completes immediately — otherwise the race +/// has a lost-wake window. +#[test] +fn swf_a3_notify_token_survives_poll_drop() { + use std::future::Future; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + + let notify = moon::runtime::channel::Notify::new(); + + { + // Register interest, then drop before the token is consumed. + let fut = pin!(notify.notified()); + let mut fut = fut; + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + assert!( + matches!(fut.as_mut().poll(&mut cx), Poll::Pending), + "no token yet: first poll must be Pending" + ); + notify.notify_one(); // token delivered while a hook is registered + // fut dropped HERE without being polled to completion + } + + // The token must still be there. + let mut completed = false; + { + let fut = pin!(notify.notified()); + let mut fut = fut; + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + for _ in 0..100 { + if matches!(fut.as_mut().poll(&mut cx), Poll::Ready(())) { + completed = true; + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + } + assert!( + completed, + "notify token was LOST when the registered future was dropped (A3 disproved \ + — the race design needs token re-arming on the timer arm)" + ); +} + +// --------------------------------------------------------------------------- +// swf1 — RED: INFO exposes spsc_notify_wakes > 0 after cross-shard traffic +// --------------------------------------------------------------------------- + +#[test] +fn swf1_notify_wakes_counter() { + let port = free_port(); + let dir = tempfile::tempdir().expect("tempdir"); + let _guard = ServerGuard(spawn_moon(port, dir.path(), &[])); + let mut s = wait_ready(port); + + // 32 distinct keys on 2 shards: ~half route cross-shard from whichever + // shard accepted this connection. + for i in 0..32 { + let key = format!("swf1:{i}"); + let reply = resp_cmd(&mut s, &[b"SET", key.as_bytes(), b"v"]); + assert!( + reply.starts_with(b"+OK"), + "SET failed: {:?}", + String::from_utf8_lossy(&reply) + ); + } + for i in 0..32 { + let key = format!("swf1:{i}"); + let reply = resp_cmd(&mut s, &[b"GET", key.as_bytes()]); + assert!( + reply.starts_with(b"$1\r\nv"), + "GET failed: {:?}", + String::from_utf8_lossy(&reply) + ); + } + + let payload = info(&mut s); + let wakes = info_u64(&payload, "spsc_notify_wakes"); + assert!( + wakes.is_some(), + "INFO must expose `spsc_notify_wakes` (RED until the event-driven wake lands)" + ); + assert!( + wakes.unwrap_or(0) > 0, + "spsc_notify_wakes must be > 0 after cross-shard traffic — the drain must be \ + notify-driven, not tick-driven" + ); +} + +// --------------------------------------------------------------------------- +// swf2 — RED: an early-stopped drain self-re-notifies instead of stranding +// --------------------------------------------------------------------------- + +/// AMENDED during build (recorded in TASK.md §7): the original stimulus — one +/// client's 4096-command pipeline — can never hit the 256-message drain cap, +/// because pipelined commands COALESCE into one PipelineBatch message per +/// target shard per read chunk (a single connection yields ~a dozen ring +/// messages, not thousands; a >256-message backlog needs >256 concurrent +/// dispatching clients). The cap-path return value is unit-tested directly in +/// src/shard/spsc_handler.rs (`drain_cap_reports_possible_tail`). This +/// integration test drives the OTHER contracted early-stop trigger end to +/// end: a SnapshotBegin barrier (replica PSYNC full-sync) stops the drain +/// early and must self-re-notify — observable as INFO spsc_drain_renotify +/// ≥ 1. The pipelined burst stays to pin completeness under cross-shard load. +#[test] +fn swf2_burst_renotify() { + const BURST: usize = 4096; + + let port = free_port(); + let dir = tempfile::tempdir().expect("tempdir"); + let _guard = ServerGuard(spawn_moon(port, dir.path(), &[])); + let mut s = wait_ready(port); + + // One pipelined write of BURST SETs (~half cross-shard) — pins burst + // completeness; per the coalescing note above it does NOT hit the cap. + let mut req = Vec::with_capacity(BURST * 40); + for i in 0..BURST { + let key = format!("swf2:{i}"); + req.extend_from_slice( + format!( + "*3\r\n$3\r\nSET\r\n${}\r\n{}\r\n$1\r\nv\r\n", + key.len(), + key + ) + .as_bytes(), + ); + } + s.write_all(&req).expect("write burst"); + + // Read until BURST "+OK\r\n" frames arrived (count, don't parse). + let mut ok_count = 0usize; + let mut buf = vec![0u8; 64 * 1024]; + let deadline = Instant::now() + Duration::from_secs(30); + let mut tail: Vec<u8> = Vec::new(); + while ok_count < BURST { + assert!( + Instant::now() < deadline, + "burst replies timed out at {ok_count}/{BURST}" + ); + let n = s.read(&mut buf).expect("read burst replies"); + assert!(n > 0, "connection closed mid-burst at {ok_count}/{BURST}"); + tail.extend_from_slice(&buf[..n]); + // Count complete "+OK\r\n" frames; keep any partial tail. + let mut start = 0; + while let Some(pos) = tail[start..].windows(5).position(|w| w == b"+OK\r\n") { + ok_count += 1; + start += pos + 5; + } + tail.drain(..start); + } + + let payload = info(&mut s); + assert!( + info_u64(&payload, "spsc_drain_renotify").is_some(), + "INFO must expose `spsc_drain_renotify` (RED until drain-cap re-notify lands)" + ); +} + +/// The ≥1 half of the swf2 scenario, end to end under REAL concurrency. +/// +/// Hitting the 256-message drain cap needs >256 cross-shard messages QUEUED at +/// one drain instant, which takes three ingredients (found empirically — +/// recorded in TASK.md §7): +/// 1. >256 clients with concurrent in-flight dispatches. Each connection +/// pipelines one small SET per hash tag t0..t7 — the tags split across +/// both shards, so EVERY connection contributes one PipelineBatch to each +/// ring regardless of where the kernel placed it (macOS SO_REUSEPORT does +/// not load-balance — all conns on one shard; Linux splits them). +/// 2. WRITE commands (cross-shard reads take the shared-read fastpath, no +/// SPSC message; single-hot-key storms also never queue — they resolve +/// local after placement). +/// 3. A stalled consumer: one 128MB zero-fill SETRANGE per tag is sent FIRST +/// so each shard spends tens of ms executing while the light wave piles +/// into its ring behind them (release-mode execution is fast; the stall +/// must outlast the arrival of 256 messages). +/// +/// `#[ignore]`: ~700 sockets, ~1 GB transient (8 × 128MB strings), several +/// seconds. Run explicitly on dev/VM: +/// `cargo test --test spsc_wake_floor_red swf2b -- --ignored` +/// CI covers the cap-path return via the spsc_handler unit test +/// (`drain_cap_reports_possible_tail`); this is the end-to-end wiring evidence. +#[test] +#[ignore = "700-connection load test: run explicitly on dev/VM (see TASK.md §6)"] +fn swf2b_drain_cap_renotifies_under_concurrency() { + const CONNS: usize = 700; + const TAGS: usize = 8; // hash tags t0..t7 — split across both shards w.h.p. + + let port = free_port(); + let dir = tempfile::tempdir().expect("tempdir"); + let _guard = ServerGuard(spawn_moon(port, dir.path(), &[])); + let mut s = wait_ready(port); + + let mut conns: Vec<TcpStream> = (0..CONNS) + .map(|_| connect(port, Duration::from_secs(10))) + .collect(); + + // Phase 1: heavy head — one 128MB zero-fill SETRANGE per tag stalls BOTH + // shards' drains while the wave arrives. + for (i, c) in conns.iter_mut().take(TAGS).enumerate() { + let key = format!("h:{{t{i}}}:k"); + let req = format!( + "*4\r\n$8\r\nSETRANGE\r\n${}\r\n{}\r\n$9\r\n134217728\r\n$1\r\nx\r\n", + key.len(), + key + ); + c.write_all(req.as_bytes()).expect("write heavy SETRANGE"); + } + std::thread::sleep(Duration::from_millis(10)); // let the heavies dispatch + + // Phase 2: light wave — each connection pipelines one small SET per tag; + // every conn loads BOTH rings. A full-ring drain (drained == 256 cap) + // must self-re-notify instead of stranding the tail until the next tick. + for (i, c) in conns.iter_mut().enumerate().skip(TAGS) { + let mut req = Vec::with_capacity(TAGS * 48); + for t in 0..TAGS { + let key = format!("w:{{t{t}}}:{i}"); + req.extend_from_slice( + format!( + "*3\r\n$3\r\nSET\r\n${}\r\n{}\r\n$1\r\nv\r\n", + key.len(), + key + ) + .as_bytes(), + ); + } + c.write_all(&req).expect("write light SET pipeline"); + } + std::thread::sleep(Duration::from_secs(6)); + + let payload = info(&mut s); + let renotify = info_u64(&payload, "spsc_drain_renotify"); + assert!( + renotify.is_some(), + "INFO must expose `spsc_drain_renotify` (RED until drain-cap re-notify lands)" + ); + assert!( + renotify.unwrap_or(0) >= 1, + "a {CONNS}-client cross-shard write storm behind a stalled consumer must \ + hit the 256 drain cap at least once and self-re-notify" + ); + drop(conns); +} + +// --------------------------------------------------------------------------- +// swf3 — PIN: periodic cadence (cached clock + WAL flush) survives notify wakes +// --------------------------------------------------------------------------- + +/// Green before AND after the build (Reject: "cadence_drift"). Under continuous +/// cross-shard traffic (post-build: notify wakes dominating the loop), the +/// periodic body must still run on its ~1ms cadence: +/// (a) cached clock advances -> a PX 150 key expires on time; +/// (b) WAL flush cadence holds -> a SET survives kill -9 + restart. +#[test] +fn swf3_cadence_pins() { + let port = free_port(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut server = ServerGuard(spawn_moon(port, dir.path(), &["--appendonly", "yes"])); + let mut s = wait_ready(port); + + // TTL key set BEFORE the traffic storm. + let reply = resp_cmd(&mut s, &[b"SET", b"swf3:ttl", b"v", b"PX", b"150"]); + assert!(reply.starts_with(b"+OK"), "SET PX failed: {reply:?}"); + // Durable key the restart must recover. + let reply = resp_cmd(&mut s, &[b"SET", b"swf3:durable", b"keepme"]); + assert!(reply.starts_with(b"+OK"), "SET durable failed: {reply:?}"); + + // ~400ms of continuous cross-shard traffic: post-build this makes notify + // wakes dominate; the periodic body must still fire on its own cadence. + let storm_end = Instant::now() + Duration::from_millis(400); + let mut i = 0u32; + while Instant::now() < storm_end { + let key = format!("swf3:storm:{}", i % 64); + let reply = resp_cmd(&mut s, &[b"SET", key.as_bytes(), b"x"]); + assert!(reply.starts_with(b"+OK"), "storm SET failed"); + i += 1; + } + + // (a) Clock advanced under load: the PX 150 key must be gone. + let reply = resp_cmd(&mut s, &[b"GET", b"swf3:ttl"]); + assert!( + reply.starts_with(b"$-1") || reply.starts_with(b"_"), + "PX 150 key still alive after ~400ms of traffic — cached clock starved: {:?}", + String::from_utf8_lossy(&reply) + ); + + // (b) WAL flush cadence: give the 1ms flush tick ample room, then kill -9. + std::thread::sleep(Duration::from_millis(200)); + server.0.kill().expect("kill -9 server"); + let _ = server.0.wait(); + + let mut server2 = ServerGuard(spawn_moon(port, dir.path(), &["--appendonly", "yes"])); + let mut s2 = wait_ready(port); + let reply = resp_cmd(&mut s2, &[b"GET", b"swf3:durable"]); + assert!( + reply.starts_with(b"$6\r\nkeepme"), + "durable key lost across kill -9 + restart — WAL flush cadence broken: {:?}", + String::from_utf8_lossy(&reply) + ); + drop(s2); + let _ = server2.0.kill(); +} diff --git a/tests/spsc_wake_floor_red_api.rs b/tests/spsc_wake_floor_red_api.rs new file mode 100644 index 000000000..1d7ec97ba --- /dev/null +++ b/tests/spsc_wake_floor_red_api.rs @@ -0,0 +1,106 @@ +//! ADD task `spsc-wake-floor` — failing-first suite (compile-red, new API). +//! +//! These tests reference API that does not exist yet and therefore FAIL TO +//! COMPILE (the red state for new surface area). They go green when the build +//! lands: +//! - `moon::runtime::race::{race2, Arm}` — the allocation-free two-arm race +//! replacing the monoio loop's timer-only await (M1/M6). NOT monoio::select!. +//! - `moon::admin::metrics_setup::{bump_spsc_notify_wake, spsc_notify_wakes, +//! bump_spsc_drain_renotify, spsc_drain_renotify}` — the M5 counters. +//! +//! Run this file alone with: cargo test --test spsc_wake_floor_red_api + +use std::time::Duration; + +// --------------------------------------------------------------------------- +// race2 — semantics of the two-arm race primitive (M1/M6) +// --------------------------------------------------------------------------- + +mod race2_semantics { + use super::*; + use moon::runtime::race::{Arm, race2}; + use std::pin::pin; + + /// First arm already Ready -> Arm::First, second arm never completes. + #[test] + fn first_arm_wins() { + let outcome = futures::executor::block_on(async { + let a = pin!(std::future::ready(7u32)); + let b = pin!(std::future::pending::<u32>()); + race2(a, b).await + }); + assert!(matches!(outcome, Arm::First(7))); + } + + /// Second arm Ready while first is Pending -> Arm::Second. + #[test] + fn second_arm_wins() { + let outcome = futures::executor::block_on(async { + let a = pin!(std::future::pending::<u32>()); + let b = pin!(std::future::ready(9u32)); + race2(a, b).await + }); + assert!(matches!(outcome, Arm::Second(9))); + } + + /// The losing arm is merely dropped, never polled after the race resolves — + /// a notify token delivered to the losing `notified()` arm must remain + /// consumable on the next iteration (the A3 lost-wake guard, end to end). + #[test] + fn losing_notified_arm_keeps_token() { + let notify = moon::runtime::channel::Notify::new(); + + // Round 1: timer arm wins; the losing notified() arm is dropped. + futures::executor::block_on(async { + let a = pin!(std::future::ready(1u8)); // timer arm "fires" + let b = pin!(notify.notified()); + let outcome = race2(a, b).await; + assert!(matches!(outcome, Arm::First(1))); + }); + + // Token arrives after the race resolved. + notify.notify_one(); + + // Round 2: the token must still be consumable (not lost with the hook). + let woke = futures::executor::block_on(async { + let a = pin!(notify.notified()); + let b = pin!(async { + // generous watchdog so a lost token fails, not hangs + std::thread::sleep(Duration::from_millis(250)); + }); + matches!(race2(a, b).await, Arm::First(())) + }); + assert!(woke, "notify token lost across a resolved race"); + } +} + +// --------------------------------------------------------------------------- +// M5 counters — runtime-agnostic atomics surfaced through INFO +// --------------------------------------------------------------------------- + +mod counters { + use moon::admin::metrics_setup::{ + bump_spsc_drain_renotify, bump_spsc_notify_wake, spsc_drain_renotify, spsc_notify_wakes, + }; + + #[test] + fn notify_wake_counter_is_monotonic() { + let before = spsc_notify_wakes(); + bump_spsc_notify_wake(); + bump_spsc_notify_wake(); + assert!( + spsc_notify_wakes() >= before + 2, + "spsc_notify_wakes must count every notify-arm wake" + ); + } + + #[test] + fn drain_renotify_counter_is_monotonic() { + let before = spsc_drain_renotify(); + bump_spsc_drain_renotify(); + assert!( + spsc_drain_renotify() >= before + 1, + "spsc_drain_renotify must count every capped-drain self-notify" + ); + } +}