Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/adr-keeper/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ accordingly.
**Status is explicit.** Each ADR must have a clear status: Proposed, Accepted, Rejected, Superseded.

**Concise over comprehensive.** Focus on the decision, context, and consequences. Avoid unnecessary detail.

**No changelog language, no ticket numbers.** An ADR records why a decision was made, not when it shipped or which task tracked it. Never write release/version numbers (e.g. "ships in 1.5.2"), ticket/task IDs (e.g. "CE-033"), or shipped-vs-pending status framing tied to a release inside an ADR's Context/Decision/Alternatives/Consequences — that content is transitory and belongs in `CHANGELOG.md` or `TODO.md`, which track it properly. An ADR's own `Status` field (Proposed/Accepted/Rejected/Superseded) is the only place decision lifecycle belongs, and it describes the *decision's* status, not a release's. If a decision text leans on "not yet shipped" or a ticket ID to make sense, that is a sign the reasoning itself isn't decoupled from the release calendar yet — rewrite it so the decision stands on its own. The only permissible exception is when an ADR governs the decision for a breaking change.
</essential_principles>

<intake>
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/adr-keeper/references/adr-index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR Index

**Last updated:** 2026-08-16
**Last updated:** 2026-08-19
**Total ADRs:** 18

| # | ADR | Status | Related Requirements |
Expand Down
11 changes: 4 additions & 7 deletions .agents/skills/architect/workflows/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,11 @@ Propose a solution. For each major decision: state the choice, name alternatives

Show the design to the user and wait for confirmation before writing documents.

## Step 5: Update ARCHITECTURE.md
## Step 5: Update ARCHITECTURE.md and record the decision as an ADR

Update relevant sections to reflect the agreed design. Add a row to the Key Decisions table for every significant decision:
ARCHITECTURE.md is a high-level overview only — it does not hold decision records (see its introductory note: "For detailed architectural decisions, see the ADR directory"). Update relevant narrative sections to reflect the agreed design, keeping it accurate and concise — it is the developer's primary reference. (Tech-writer will update it again post-implementation for code-level accuracy.)

| Decision | Choice | Alternatives Considered | Rationale |
|----------|--------|------------------------|-----------|

Keep it accurate and concise — it is the developer's primary reference. (Tech-writer will update it again post-implementation for code-level accuracy.)
For every significant decision, use the `adr-keeper` skill to create a new ADR (choice, alternatives considered, rationale, status). If the decision is a backport, extension, or narrower application of an existing ADR's vocabulary rather than a fresh choice, create a new ADR that references the original as related — do not edit an Accepted ADR in place (ADRs are immutable once accepted; see `adr-keeper`'s own rules) and do not duplicate its content back into ARCHITECTURE.md.

## Step 6: Write tasks to TODO.md

Expand All @@ -43,7 +40,7 @@ Create `TODO.md` at the project root if it doesn't exist.

<success_criteria>
- Design traces back to REQUIREMENTS.md — no features invented
- Key Decisions table updated in ARCHITECTURE.md for every significant choice
- An ADR created (via `adr-keeper`) for every significant choice; ARCHITECTURE.md updated only for high-level narrative, not decision records
- TODO.md has ordered tasks with IDs and sufficient context
- No task requires the developer to guess intent or make architectural decisions
- Open questions resolved or escalated to the user
Expand Down
9 changes: 5 additions & 4 deletions .agents/skills/cause-effect-dev/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
---
name: cause-effect-dev
description: >
Expert developer for the @zeix/cause-effect reactive signals library. Use when
implementing features, fixing bugs, writing tests, or answering questions about
the library's internals, public API, or design decisions.
Expert developer for the @zeix/cause-effect reactive signals library. Use when implementing features, fixing bugs, writing tests, or answering questions about the library's internals, public API, or design decisions.
user_invocable: false
---

Expand All @@ -22,6 +20,9 @@ For consumer projects that use `@zeix/cause-effect` as a dependency, use the `ca

**Run `bun test`** after every change.

**No ticket numbers in source or tests.** Never write an issue, PR, or ticket number
(`#123`, `CE-456`) into a source comment or a test description. The branch or ticket closes and the reference goes stale — a future reader can't resolve it. Describe the behavior, constraint, or bug being guarded against directly instead; put ticket references in the commit message, where they belong.

**Composite derivation over effect-driven writes.** `adr/0018-shape-indexed-signal-types.md` documents the anti-pattern the library actively closes: an effect that reads a `Task`/`Memo` and writes the result into a `Store`/`List` with `.set()`. When implementing or reviewing a feature that fills one signal from another, prefer extending `deriveStore`/`deriveList`'s async and per-item derivation paths over adding a write path — a discouraged pattern surviving only because no derivation covers the case is itself a signal the derivation surface has a gap.
</essential_principles>

Expand Down Expand Up @@ -83,4 +84,4 @@ All in `workflows/`:
| write-tests.md | Write or update tests for a signal type or behavior |
| answer-question.md | Answer questions about the API, internals, or design |
| update-perf-baseline.md | Re-point the performance regression test at a new release |
</workflows_index>
</workflows_index>
6 changes: 3 additions & 3 deletions .agents/skills/changelog-keeper/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
---
name: changelog-keeper
description: >
Maintain CHANGELOG.md for the @zeix/cause-effect library. Use after meaningful code
changes, when asked to add release notes, or to prepare a release.
Maintain CHANGELOG.md for the @zeix/cause-effect library. Use after meaningful code changes, when asked to add release notes, or to prepare a release.
user_invocable: true
---

Expand Down Expand Up @@ -67,4 +66,5 @@ When asked to release a version:
- **Include migration notes** under Changed or Removed when behavior breaks compatibility.
State clearly what consumers must change and why.
- Use backticks for all public API names, internal types, flags, and file names.
</entry_style>
- **No ticket numbers.** Never reference an issue, PR, or ticket number (`#123`, `CE-456`). It becomes a dead link once the branch or ticket closes. Describe the change itself — that is what a consumer needs, and it does not go stale.
</entry_style>
16 changes: 6 additions & 10 deletions .agents/skills/tech-writer/references/tone-guide.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
<overview>
Writing tone, register, and conciseness rules for each document maintained by the tech-writer
skill. Violating the tone is as wrong as a factual error — each document has a distinct
primary reader and serves a distinct purpose.
Writing tone, register, and conciseness rules for each document maintained by the tech-writer skill. Violating the tone is as wrong as a factual error — each document has a distinct primary reader and serves a distinct purpose.
</overview>

<shared_rules>
Expand All @@ -15,10 +13,9 @@ These rules apply to every document without exception:
- **No changelog language in documentation.** Documents state current truth. Never write
"previously", "as of version X", "we changed", or "now supports". Those belong in
CHANGELOG.md.
- **No meta-commentary.** Do not write "This section explains…" or "See below for…".
Say the thing directly.
- **Backtick all code.** Every API name, flag, file name, type name, option key, and
shell command is wrapped in backticks, even mid-sentence.
- **No meta-commentary.** Do not write "This section explains…" or "See below for…". Say the thing directly.
- **Backtick all code.** Every API name, flag, file name, type name, option key, and shell command is wrapped in backticks, even mid-sentence.
- **No ticket numbers.** Never reference an issue, PR, or ticket number (`#123`, `CE-456`) in an authoritative document or JSDoc. The branch or ticket closes; the reference goes stale and means nothing to a future reader. State the current behavior or constraint on its own terms — if history matters, it belongs in a commit message or `CHANGELOG.md` entry, not here.
</shared_rules>

<README>
Expand Down Expand Up @@ -225,6 +222,5 @@ acceptable if they read naturally as a tooltip.
- `@param type` annotations — TypeScript already shows the type
- JSDoc that restates the TypeScript signature in prose
- Generic descriptions that would apply to any function ("Creates and returns a…")
- Multi-paragraph descriptions — if it needs that much explanation, the API design may
need review
</jsdoc>
- Multi-paragraph descriptions — if it needs that much explanation, the API design may need review
</jsdoc>
14 changes: 0 additions & 14 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,20 +200,6 @@ For a derived external-push Store (`deriveStore(seed, { watched })`), per-proper

Return types remain honest: `byKey(k): S | undefined` etc. on List/Collection (a runtime string may not be a present key). `Store.byKey` is non-nullable because Store keys are statically known from `T`.

## Key Decisions

| Decision | Choice | Alternatives Considered | Rationale |
|----------|--------|------------------------|-----------|
| Sync callback returning a `Promise` in Memo/Slot | Throw `PromiseValueError` in `recomputeMemo()` (`graph.ts`) the first time a non-`async` callback's return value is thenable | (a) Auto-detect ahead of time in `isAsyncFunction` and reclassify as Task; (b) leave as silent, undocumented misclassification (status quo) | Reclassifying requires invoking the callback before the Memo/Task routing decision is made, which breaks Memo's lazy-evaluation contract and is unreliable for branchy functions. Morphing a live `MemoNode` into a `TaskNode` after callers already hold a `Memo<T>` would silently change `.get()` semantics (synchronous return vs. throws-until-resolved) with no compile-time signal — unsound. A single check at the existing `recomputeMemo()` choke point catches Memo, Slot, and `createComputed`/`createSignal` misuse uniformly, costs one `typeof` check per recompute, and only fires on code that was already broken (non-breaking, "Fixed" changelog category). |
| Effects writing signals they depend on | Bounded convergence: `flush()` drains in passes over queue snapshots (Svelte-style), so self-writing effects re-run until settled and always observe final values; a graph that doesn't settle within 1000 passes throws `EffectConvergenceError`. `propagate()` preserves `FLAG_RUNNING` on effects and `flush()` skips mid-run effects, preventing re-entrant `runEffect` during creation-time writes | (a) Status quo (rejected: `runEffect` clobbered the effect's own dirty re-mark with `CLEAN`, so even converging clamp effects ended one run stale — rendering the pre-clamp value — and mutual effect writes looped until heap exhaustion with no guard); (b) per-effect run cap (rejected: cannot detect ping-pong between effects — each effect individually settles every pass); (c) raw queue-length cap (rejected: false-positives on wide graphs with many distinct dirty effects) | A pass cap is the only metric that is both sound and complete: a settled graph does exactly one pass regardless of effect count, while any non-converging cycle — self-loop, ping-pong, or longer — forces unbounded passes. Converging self-writes (clamping, write-once init) remain supported; non-settling graphs fail loudly at the triggering `set()` instead of silently diverging or hanging. |
| Signal type taxonomy (v2.0, proposed) | Index types by shape (single / keyed sequence / keyed record) × mutability = 6 types, plus `Effect` and `Slot`. Origin moves to the construction verb: `create*` → mutable, `derive*` → readonly. Every shape becomes derivable from every origin. See [ADR-0018](adr/0018-shape-indexed-signal-types.md) | (a) Fill the empty matrix cells but keep 9 type names (rejected: leaves `.set()` reachable on derived values, so the discouraged pattern stays available and idiomatic-looking); (b) document the prohibition harder (rejected: a training-set prior is not answerable by prose — if `.set()` exists it will be called); (c) three factories total with full runtime dispatch (rejected: cannot distinguish sync derivation from external push, and pulls async + watched machinery into every bundle, breaking the ≤4 kB core budget); (d) twelve `create{Origin}{Shape}` factories (rejected: 12 construction names for 6 types is a worse ratio than the status quo) | Users write to state from effects because the derivation matrix has holes — `Task<T[]>` → keyed sequence and any source → `Store` have no derivation path at all, so the effect is the only door. Closing the holes and removing the setter from derived types are individually insufficient and jointly sufficient: the first makes the correct path exist, the second makes the incorrect one a compile error. Collapsing the taxonomy falls out for free, because `Sensor` is already a `State` without a setter and `Collection` is already a `List` without mutators — both are mutability distinctions wearing the costume of type distinctions |
| `isPending` / `abort` placement (v2.0, proposed) | Free functions in the graph utilities, alongside `batch`, `untrack`, and `match` | (a) Methods on `Task`, as in v1.x (rejected: after the collapse this forces `AsyncSignal`/`AsyncList`/`AsyncStore` subtypes, restoring a 9-name taxonomy); (b) methods on the base `Signal<T>` (rejected: a closure per node on the synchronous hot path, paid by every `State`, for a capability most signals do not have) | Asynchrony is an origin, not a shape. Any of the three shapes can be derived asynchronously, so the accessor must be shape-agnostic. The `pendingNode: StateNode<boolean>` mechanism of ADR-0001 is unchanged — only the accessor moves and its domain widens. Consumers gain the ability to ask a derived `List` or `Store` whether it is still loading, which no v1.x API expresses |
| Unset state of async composites (v2.0, proposed) | `options.initial` is required for `deriveList`/`deriveStore` with an async input. `deriveCell` keeps `UnsetSignalValueError` and gains `initial` as optional | (a) Throw like `Task` (rejected: `length` and `Symbol.iterator` throwing is a sharp edge, and every consumer would need `match()` merely to read a derived collection); (b) default silently to `[]` / `{}` (rejected: loading-empty becomes indistinguishable from resolved-empty at the point of use) | Requiring `initial` makes the composite never unset, so the whole lookup surface is total. `isPending()` carries the loading distinction instead of the value doing it. `deriveCell` keeps its current behaviour because `match()`'s `nil` branch depends on it |
| Bundle-size limits during refactoring | Split the figure by role: the tree-shaken core budget is a hard promise; the full-library figures are a working diagnostic with deliberate slack, re-baselined from measurement at each release and explicitly not defended during a refactor | (a) Status quo — both absolute and hard (rejected: gzip and minified move in opposite directions under deduplication, so a hard gzip limit selects against consolidating duplicated code, which is the change that most improves the codebase); (b) ratchet against the last published release with a tolerance, as `regression-performance.test.ts` does (rejected for now: it makes every refactor's budget depend on release cadence, and the failure it would catch — accidental blowup — is already caught by a generous absolute ceiling); (c) raise the limits without changing their status (rejected: leaves the same trap one branch later) | The number was doing two incompatible jobs. As a *promise* it must be hard and must reflect what a consumer actually ships — which, given tree-shaking, is the core figure, not the full-library one. As a *regression detector* it must tolerate the byte-level noise that correct refactoring produces. Separating them lets the promise stay strict while the diagnostic stops distorting design. Re-baselining is a release gate, not a routine edit: lowering the ceiling toward measured usage at release keeps it meaningful, raising it mid-branch to unblock a commit does not |
| Other 8 documented "non-obvious behaviors" (conditional reads delaying `watched`, `equals` suppressing subtrees, lazy `watched`/`unwatched` lifecycle stability, Task abort-on-change, Sensor/Task unset state, synchronous scope cleanup, `untrack` vs `watched` independence, `byKey().set()` vs `list.replace()`) | No code changes. Reframe as direct, predictable consequences of the dependency-tracking model in developer-facing docs rather than standalone gotchas | Changing `byKey().set()` to always propagate structurally (rejected: would force every item signal to carry a permanent edge to its list's structural node regardless of whether anything observes structurally, costing a `propagate()` traversal on every item write — conflicts with the "minimal work" performance constraint, and `list.replace()` already exists as the correct API for this case, pinned by `test/list.test.ts:261`) | Each behavior is either inherent to any correct fine-grained reactive graph (read-based edge creation, two-level dirty/check flagging, lazy lifecycle keyed on sink count) or an already-decided trade-off with a working escape hatch. The fix is conceptual, not code: a reader who understands the model shouldn't find these surprising. Serves REQUIREMENTS.md goal #2 (predictable mental model) without touching synchronous-path performance (goal #4). |
| 1.x v2-bridge names for the single-value shape (backported from ADR-0018 Revision 2026-08-17) | `createCell` (an alias of `createState` — the single-value mutable signal, value taken verbatim, no shape conversion), `deriveCell`, and `DeriveCellOptions` are the bridge names as of 1.5.1 (`src/signal.ts`); the 1.5.0 `deriveSignal`/`DeriveSignalOptions` remain as deprecated aliases, removed in 2.0. `createSignal` keeps its shape dispatch unchanged — 2.0 removes the dispatch with no single replacement, and the codemod flags its call sites. `Signal<T>`, `isSignal`, and `isMutableSignal` are untouched — 1.x already carries the correct umbrella meaning | (a) Keep the 1.5.0 names until 2.0 (rejected: they encode the superseded narrow-`Signal` meaning, so everyone who adopts them now — consumers and LLMs alike — faces a second rename at 2.0 and trains on the wrong vocabulary meanwhile); (b) Alias the wide `createSignal` to `createCell` (rejected: v2's `createCell` is the narrow single-value factory, so the alias would work in 1.x and silently flip meaning at the 2.0 boundary under a terminal bridge name); (c) Remove the 1.5.0 names outright in 1.5.1 (rejected: REQUIREMENTS.md mandates backward compatibility from 1.0 onward; removal is reserved for a major) | The Revision settled that `Signal` is the structural umbrella and the single-value shape is `Cell`. 1.5.0 shipped its single-value derive bridge under the superseded meaning one release before the Revision; correcting the bridge now means the 1.x→2.0 migration path teaches exactly one vocabulary, and the one-release-old names carry a deprecation pointing at the final names. |
| `watched` activation for external-push Stores (backported from v2 CE-015) | A lifecycle anchor node on the `deriveStore(seed, { watched })` facade: a source node that carries watcher edges but never holds or propagates a value; every observation form — structural (`get()`/`keys()`) or per-property (`byKey()`/proxy) — links it (`src/nodes/store.ts`) | (a) Link a structural edge on property reads (rejected: defeats per-property granularity — ADR-0015 — by making every `store.prop` read subscribe to "any key changed"); (b) Activate eagerly at construction (rejected: breaks the lazy lifecycle contract and starts external resources nobody watches) | An external-push Store has no value source other than `watched`'s `emit`, so property-only consumption must activate the lifecycle or the store stays frozen at its seed forever. The anchor reuses `unlink()`'s existing sink-count lifecycle (ADR-0011) without creating any graph edge, so activation and tracking remain separate concerns. |

## Testing Strategy

All tests live in `test/`. The `test` script runs the full suite. There is no formal separation of unit and integration tests.
Expand Down
Loading
Loading