@danieljvdm/dev-kit 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -137,6 +137,7 @@ the root package itself.
137
137
  | `dev-kit gitignore` | Add `.repos/` and `.dev-kit/` to `.gitignore`. |
138
138
  | `dev-kit effect sync` | Sync `.repos/effect` to the installed Effect version. |
139
139
  | `dev-kit tsgo patch` | Validate and patch Effect TypeScript-Go directly. |
140
+ | `dev-kit cache prune` | Evict stale machine-global cache content (`--all` wipes). |
140
141
  | `dev-kit catalog refresh` | Maintainer command to approve current upstream refs. |
141
142
  | `dev-kit catalog add <repository>` | Inspect a repository and approve selected skills. |
142
143
  | `dev-kit catalog remove <source-or-skill>` | Revoke an approval (`--yes` outside a terminal). |
@@ -200,7 +201,8 @@ tool versions. A project-local process lock also prevents concurrent applies.
200
201
 
201
202
  - `dev-kit` installs guidance for operating the toolkit itself.
202
203
  - `effect` is a built-in family: the `effect-ts` bootstrap,
203
- `effect-architecture-audit`, `build-effect-apis`, and `build-effect-clis`.
204
+ `effect-architecture-audit`, `build-effect-apis`, `effect-atom-state`, and
205
+ `build-effect-clis`.
204
206
  - External Git skills (`workers-best-practices`, `serve-sim`, …) are selected
205
207
  individually after scanning the project for relevant technologies. An
206
208
  approved source ID selects every skill from that source; use it only when
@@ -363,6 +365,53 @@ The scaffolded workflow performs one frozen, script-suppressed install, runs
363
365
  [Vite Task run guide](https://viteplus.dev/guide/run) when maintaining the
364
366
  templates.
365
367
 
368
+ ### Absolute (path-alias) imports
369
+
370
+ Opt into enforcing path-alias imports for selected app sources. The factory
371
+ appends an Oxlint override that errors on `../` imports inside the given
372
+ globs, so those files import through tsconfig path aliases such as `@/*`:
373
+
374
+ ```ts
375
+ export default defineConfig(
376
+ createRecommendedVitePlusConfig({
377
+ absoluteImports: {
378
+ files: ["apps/app/src/**/*.{ts,tsx}", "apps/mobile/src/**/*.{ts,tsx}"],
379
+ },
380
+ }),
381
+ );
382
+ ```
383
+
384
+ The lint rule only forbids relative parent imports; each covered app must map
385
+ the alias itself. Add the `paths` entry to the app's `tsconfig.json`:
386
+
387
+ ```jsonc
388
+ {
389
+ "compilerOptions": {
390
+ "paths": { "@/*": ["./src/*"] },
391
+ },
392
+ }
393
+ ```
394
+
395
+ Vite-built apps also resolve the alias from the same tsconfig by setting
396
+ `resolve: { tsconfigPaths: true }` in the app's Vite config. Expo apps need no
397
+ Metro configuration: Expo SDK 49+ resolves tsconfig `paths` by default.
398
+
399
+ Standalone Oxlint projects compose the same override directly:
400
+
401
+ ```ts
402
+ // oxlint.config.ts
403
+ import {
404
+ createAbsoluteImportsOxlintOverride,
405
+ recommendedOxlintConfig,
406
+ } from "@danieljvdm/dev-kit/oxlint";
407
+ import { defineConfig } from "oxlint";
408
+
409
+ export default defineConfig({
410
+ extends: [recommendedOxlintConfig],
411
+ overrides: [createAbsoluteImportsOxlintOverride({ files: ["src/**/*.{ts,tsx}"] })],
412
+ });
413
+ ```
414
+
366
415
  ## Worktrunk project config
367
416
 
368
417
  Enable a scaffolded default [Worktrunk](https://worktrunk.dev) project config
@@ -413,9 +462,12 @@ Enable a local checkout of the exact installed Effect release in the manifest:
413
462
  ```
414
463
 
415
464
  `dev-kit apply` reads `node_modules/effect/package.json`, then shallow-clones or
416
- updates `.repos/effect` to the detached `effect@<version>` tag. It skips the
417
- checkout in CI, leaves the repository in place when the task is disabled, and
418
- refuses to switch a checkout with local changes or an unexpected origin.
465
+ updates `.repos/effect` to the detached `effect@<version>` tag. Tag fetches go
466
+ through a shared clone in the machine-global cache, so a new worktree or
467
+ project reuses tags already cached on the machine and only contacts the
468
+ network for a tag the cache has never seen. It skips the checkout in CI,
469
+ leaves the repository in place when the task is disabled, and refuses to
470
+ switch a checkout with local changes or an unexpected origin.
419
471
 
420
472
  The path, package name, and repository URL may be overridden for compatible
421
473
  Effect package layouts. Use `dev-kit effect sync --dry-run` to inspect this
@@ -587,9 +639,23 @@ names and paths, rejects symlinks and collisions, extracts descriptions, and
587
639
  updates `skill-sources.lock.json`.
588
640
 
589
641
  When a project selects one of these Git-backed skills, Dev Kit fetches the
590
- approved commit into the ignored `.dev-kit/cache` and installs it through the
591
- same ownership-safe sync path. Only a reviewed catalog refresh changes the
592
- approved Git content.
642
+ approved commit into a machine-global cache and installs it through the same
643
+ ownership-safe sync path. Cache entries are keyed by resolved commit SHA, so
644
+ every project and git worktree on the machine shares one download; a warm
645
+ `dev-kit plan` or `dev-kit apply` performs no network operations. The cache
646
+ lives in `$XDG_CACHE_HOME/dev-kit` (falling back to `~/Library/Caches/dev-kit`
647
+ on macOS and `~/.cache/dev-kit` elsewhere) and may be overridden with
648
+ `DEV_KIT_CACHE_DIR`; it is safe to delete at any time. Populating this
649
+ immutable commit-keyed cache is not project state, so planning and `--locked`
650
+ verification use it too. Only a reviewed catalog refresh changes the approved
651
+ Git content.
652
+
653
+ The cache does not grow without bounds: every use refreshes a recency stamp,
654
+ and `dev-kit apply` sweeps content unused for 30 days at most once a day.
655
+ Shared Effect repositories drop unused tags individually and are removed whole
656
+ once empty. Run `dev-kit cache prune` to sweep on demand (`--max-age-days` to
657
+ tune the threshold, `--all` to clear the cache entirely); an evicted entry is
658
+ simply fetched again the next time a project needs it.
593
659
 
594
660
  ## Oxlint and Oxfmt configurations
595
661
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieljvdm/dev-kit",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "description": "Declarative project development toolkit with portable agent skills.",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: build-effect-apis
3
- description: Build and consume contract-first Effect HTTP APIs. Use when defining shared HttpApiEndpoint/HttpApiGroup contracts, implementing HttpApiBuilder or HttpApiServer handlers and middleware, deriving HttpApiClient or Effect Atom clients, or integrating those APIs with TanStack Start and Cloudflare Workers/effect-cf.
3
+ description: Build contract-first Effect HTTP APIs. Use when defining shared HttpApiEndpoint/HttpApiGroup contracts, implementing HttpApiBuilder or HttpApiServer handlers and middleware, assembling server runtimes and OpenAPI docs, or serving on Cloudflare Workers/effect-cf. For consuming an API from client state, use $effect-atom-state.
4
4
  ---
5
5
 
6
6
  # Build Effect APIs
@@ -9,12 +9,11 @@ Treat the shared `HttpApi` value as the **contract spine**: schemas, server,
9
9
  OpenAPI, and clients all derive from it. Keep transport contracts isomorphic;
10
10
  keep runtime behavior in handlers, services, layers, and client state modules.
11
11
 
12
- Effect HTTP and reactivity APIs are version-sensitive. Read the target
13
- repository's manifests and lockfile, inspect its existing imports, and confirm
14
- exact signatures from the installed package declarations before editing. In
15
- current Effect v4 betas the server module is `HttpApiBuilder`; a request that
16
- mentions `HttpApiServer` may refer to the same server-building responsibility
17
- from another version.
12
+ Effect HTTP APIs are version-sensitive. Read the target repository's manifests
13
+ and lockfile, inspect its existing imports, and confirm exact signatures from
14
+ the installed package declarations before editing. In current Effect v4 betas
15
+ the server module is `HttpApiBuilder`; a request that mentions `HttpApiServer`
16
+ may refer to the same server-building responsibility from another version.
18
17
 
19
18
  ## Build the contract spine
20
19
 
@@ -31,35 +30,21 @@ from another version.
31
30
  decoded boundary adapters into application services and assemble all
32
31
  requirements at the runtime edge. Finish when each endpoint identifier has
33
32
  exactly one handler and every declared middleware has a provided layer.
34
- 4. Choose the consumer branch:
35
- - For React server state, read
36
- [effect-atom-client.md](references/effect-atom-client.md) and
37
- [effect-atom-lifecycle.md](references/effect-atom-lifecycle.md), then
38
- derive one `AtomHttpApi.Service`, stable query atoms or families, mutation
39
- atoms, and one reactivity-key vocabulary.
40
- - For non-React Effect code, use the direct `HttpApiClient` branch in
41
- [effect-atom-client.md](references/effect-atom-client.md).
42
-
43
- Finish when consumers call the shared contract rather than redefining wire
44
- types or using ad hoc `fetch` for declared endpoints.
45
-
33
+ 4. Route consumer changes through the `$effect-atom-state` skill: deriving
34
+ `AtomHttpApi` or direct `HttpApiClient` clients, query and mutation atoms,
35
+ reactivity keys, and React integration all live there. Finish when consumers
36
+ call the shared contract rather than redefining wire types or using ad hoc
37
+ `fetch` for declared endpoints.
46
38
  5. Read [verification.md](references/verification.md). Run the repository's
47
39
  format, lint, typecheck, and test commands. Finish when changed schemas
48
- round-trip, middleware failures use declared error channels, server and
49
- client agree on every request shape, and changed atom lifecycles have
50
- deterministic coverage.
40
+ round-trip, middleware failures use declared error channels, and server and
41
+ client agree on every request shape.
51
42
 
52
43
  ## Optional branches
53
44
 
54
45
  - Read [runtime-assembly.md](references/runtime-assembly.md) when wiring a
55
46
  conventional Node/Bun server, generated API docs, process entrypoint, or
56
47
  serverless web handler.
57
- - Read [effect-atom-testing.md](references/effect-atom-testing.md) when changing
58
- Atom cache retention, SWR, polling, invalidation, cancellation, aggregation,
59
- provider placement, SSR, or hydration behavior.
60
- - Read [tanstack-start.md](references/tanstack-start.md) when the client is
61
- TanStack Start, SSR, hydration, `ClientOnly`, loaders, server functions, or a
62
- proxied separate API.
63
48
  - Read [cloudflare-workers.md](references/cloudflare-workers.md) when the server
64
49
  runs on Cloudflare Workers or uses `effect-cf`, bindings, Durable Objects,
65
50
  Queues, WebSockets, streaming, or raw byte routes.
@@ -94,6 +79,3 @@ but the boundary and type reasoning remain the source of truth.
94
79
  - Let handlers own transport-to-application mapping and boundary invariants.
95
80
  - Let application services own orchestration, persistence, retries, and
96
81
  transactions.
97
- - Let client data modules own API services, query identity, cache policy,
98
- invalidation keys, and mutation atoms; let UI action owners own navigation,
99
- toasts, optimistic presentation, and form reset.
@@ -48,9 +48,9 @@ decoding are skipped.
48
48
  `HttpApiMiddleware.layerClient` and assert that it transforms the request.
49
49
  - Assert params, query, headers, payload, and expected errors at least once for
50
50
  every changed request shape.
51
- - For Atom clients, complete every applicable scenario routed through
52
- `effect-atom-testing.md`; use a deterministic HTTP layer so request encoding,
53
- invalidation, and lifecycle remain observable.
51
+ - For Atom clients, complete every applicable scenario in the
52
+ `$effect-atom-state` skill's testing reference; use a deterministic HTTP
53
+ layer so request encoding, invalidation, and lifecycle remain observable.
54
54
 
55
55
  ## Completion matrix
56
56
 
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: effect-atom-state
3
+ description: Manage client-side state and server data in React with Effect Atom, which fills the role TanStack/React Query, SWR, Zustand, Redux, or Jotai play in other stacks — use this skill instead of reaching for those libraries or hand-rolled useEffect fetching whenever a component needs shared state, data fetching, caching, mutations, or optimistic updates. Also use when reading or dispatching atoms (useAtomValue, useAtomSet, useAtom), refactoring promise chains or useState-held server state into atom workflows, choosing reactivity keys and invalidation, deriving AtomHttpApi or HttpApiClient clients from a shared contract, deciding where the Effect→Promise boundary sits, or wiring RegistryProvider and SSR with TanStack Start.
4
+ ---
5
+
6
+ # Effect Atom State
7
+
8
+ Effect Atom is the client-state and server-data layer in an Effect repository:
9
+ the role other stacks give TanStack Query, SWR, Zustand, Redux, or Jotai.
10
+ Every need that would reach for one of those libraries — or for hand-rolled
11
+ `useEffect` fetching — is atom work; never introduce a second state or query
12
+ library beside it.
13
+
14
+ Business logic stays in Effect for as long as possible. Components read
15
+ `AsyncResult` values and dispatch actions; workflows, optimistic updates, and
16
+ cache invalidation live in atoms, not in promise chains at the React boundary.
17
+
18
+ Effect Atom APIs are version-sensitive. Read the target repository's manifests
19
+ and lockfile, inspect its existing imports, and confirm exact signatures from
20
+ the installed `effect` and `@effect/atom-react` declarations before editing.
21
+ In current Effect v4 betas the atom modules live in
22
+ `effect/unstable/reactivity` and the React hooks in `@effect/atom-react`.
23
+
24
+ ## Build the client state graph
25
+
26
+ 1. Inventory the existing `RegistryProvider`, runtime factories,
27
+ `AtomHttpApi.Service` clients, query atoms and families, mutation and
28
+ workflow atoms, reactivity-key constructors, state atoms, and promise-mode
29
+ dispatch sites. Finish when every consumer of the affected state is
30
+ identified.
31
+ 2. Read [effect-atom-client.md](references/effect-atom-client.md), then derive
32
+ one `AtomHttpApi.Service` per contract, stable query atoms or families,
33
+ mutation atoms, and one reactivity-key vocabulary. For non-React Effect
34
+ code, use the direct `HttpApiClient` branch. Finish when consumers call the
35
+ shared contract rather than redefining wire types or using ad hoc `fetch`
36
+ for declared endpoints.
37
+ 3. Read [effect-atom-workflows.md](references/effect-atom-workflows.md), then
38
+ express every multi-step action — mutate then invalidate, optimistic echo
39
+ then rollback — as an `Atom.fn` effect composing other atoms through the fn
40
+ context. Finish when no component or route chains `.then`/`.catch` on a
41
+ dispatch and no `useState` holds state a workflow atom must own.
42
+ 4. Read [effect-atom-lifecycle.md](references/effect-atom-lifecycle.md) when
43
+ changing registry scope, atom identity, retention, freshness, polling,
44
+ cancellation, or aggregate stability.
45
+ 5. Read [effect-atom-testing.md](references/effect-atom-testing.md), then give
46
+ changed atom behavior deterministic coverage below React first, with a
47
+ deterministic HTTP layer so request encoding, invalidation, and lifecycle
48
+ remain observable. Run the repository's format, lint, typecheck, and test
49
+ commands. Finish when changed queries, mutations, invalidation, and
50
+ workflow atoms have deterministic tests and every repository check passes.
51
+
52
+ ## Optional branches
53
+
54
+ - Read [tanstack-start.md](references/tanstack-start.md) when the client is
55
+ TanStack Start, SSR, hydration, `ClientOnly`, loaders, server functions, or a
56
+ proxied separate API.
57
+ - Use the `$build-effect-apis` skill when the change reaches the contract or
58
+ server: shared `HttpApiEndpoint`/`HttpApiGroup` definitions, handlers,
59
+ middleware, or runtime assembly.
60
+
61
+ ## Keep the Promise boundary logic-free
62
+
63
+ The Effect→Promise boundary sits at the outermost edge and carries no logic.
64
+
65
+ - Return a promise-mode dispatch (`useAtomSet(action, { mode: "promise" })`)
66
+ bare to a leaf component whose contract is promise-shaped — a pending
67
+ button, a composer that owns its in-flight state. A `.then` or `.catch`
68
+ chain in a component or route is a violation: move that logic into the
69
+ action's Effect.
70
+ - Express multi-step workflows as `Atom.fn` effects composing other atoms
71
+ through the fn context: `get.setResult` awaits another fn atom, `get.set`
72
+ writes state atoms. Reads through the fn context callable are untracked, so
73
+ mutating a state atom from inside the effect cannot re-trigger the workflow.
74
+ - Declare cross-query invalidation as reactivity keys on the mutation; never
75
+ chain a manual refresh at a call site. When several `AtomHttpApi` services
76
+ share one Atom runtime, one `Reactivity` instance spans them, so a mutation
77
+ on one client invalidates another client's query keys.
78
+ - Keep optimistic UI state in `Atom.family` state atoms keyed by the entity,
79
+ not `useState`, so the workflow atom that writes it owns its lifecycle.
80
+ - Genuine view state — controlled inputs, open/closed toggles, reconciling
81
+ optimistic rows against rendered props — stays in React; do not force it
82
+ into Effect.
83
+
84
+ A repository may reinforce the boundary with a lint warning on `then` scoped
85
+ to component and route modules, with a documented local suppression for a
86
+ genuinely promise-shaped contract, but the boundary reasoning remains the
87
+ source of truth.
88
+
89
+ ## Boundary rules
90
+
91
+ - Let client data modules own API services, query identity, cache policy,
92
+ invalidation keys, mutation atoms, and workflow atoms.
93
+ - Let workflow atoms own orchestration, optimistic echo, rollback, and
94
+ cross-query invalidation.
95
+ - Let UI action owners own navigation, toasts, form reset, and presentation
96
+ derived from `AsyncResult` state.
97
+ - Let React own view state that no atom needs to write.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Effect Atom State"
3
+ short_description: "Manage Effect Atom client state and React integration"
4
+ default_prompt: "Use $effect-atom-state to move this component's client state and workflows into Effect Atom."
@@ -0,0 +1,180 @@
1
+ # Effect Atom workflows
2
+
3
+ Multi-step client actions are Effects composing atoms. Components dispatch
4
+ them and render `AsyncResult` state; no orchestration crosses the React
5
+ boundary. Confirm exact signatures against the installed
6
+ `effect/unstable/reactivity` declarations before copying.
7
+
8
+ - [Define workflow atoms with Atom.fn](#define-workflow-atoms-with-atomfn)
9
+ - [Compose atoms through the fn context](#compose-atoms-through-the-fn-context)
10
+ - [Overlay optimistic query values](#overlay-optimistic-query-values)
11
+ - [Dispatch from React](#dispatch-from-react)
12
+ - [Reinforce the boundary with a lint rule](#reinforce-the-boundary-with-a-lint-rule)
13
+
14
+ ## Define workflow atoms with Atom.fn
15
+
16
+ `Atom.fn<Input>()(effect)` creates a writable atom: writing an input runs the
17
+ effect, and the atom's value is the `AsyncResult` of the latest run. The
18
+ effect receives `(input, get: Atom.FnContext)`.
19
+
20
+ Bare `Atom.fn` accepts no reactivity keys. When the workflow needs Effect
21
+ services or invalidation, create it through a runtime factory —
22
+ `AtomHttpApi.Service` exposes its own as `Client.runtime.fn`:
23
+
24
+ ```ts
25
+ import { Effect } from "effect";
26
+ import { Atom, Reactivity } from "effect/unstable/reactivity";
27
+
28
+ export const updateProject = ApiClient.runtime.fn(
29
+ Effect.fnUntraced(function* (input: { readonly projectId: ProjectId; readonly patch: Patch }) {
30
+ const client = yield* ApiClient;
31
+
32
+ return yield* Reactivity.mutation(
33
+ client.projects.updateProject({
34
+ params: { projectId: input.projectId },
35
+ payload: input.patch,
36
+ }),
37
+ [...projectKeys.collection, ...projectKeys.project(input.projectId)],
38
+ );
39
+ }),
40
+ );
41
+ ```
42
+
43
+ Prefer `Client.mutation(group, endpoint)` with call-site `reactivityKeys` for a
44
+ single request; wrap the client call in `runtime.fn` with
45
+ `Reactivity.mutation(effect, keys)` when the key set depends on the input or
46
+ the workflow spans several requests. Either way, keys invalidate only when the
47
+ effect succeeds.
48
+
49
+ Invalidation spans clients: every `AtomHttpApi.Service` built on the same
50
+ runtime factory shares one `Reactivity` instance, so a mutation on one client
51
+ invalidates query keys registered by another. A service given its own
52
+ `Atom.context()` gets a separate `Reactivity` and cannot invalidate the rest —
53
+ share one runtime factory across API services on purpose.
54
+
55
+ ## Compose atoms through the fn context
56
+
57
+ Inside the effect, the fn context is the composition surface:
58
+
59
+ - `get.setResult(fnAtom, input)` writes another fn atom and returns an Effect
60
+ of its settled result — the await-another-workflow primitive;
61
+ - `get.set(stateAtom, value)` writes a state atom;
62
+ - `get(atom)` reads the current value **untracked** — the workflow never
63
+ subscribes, so mutating a state atom from inside the effect cannot
64
+ re-trigger it.
65
+
66
+ Optimistic echo with rollback, keyed by the owning entity so the atoms
67
+ dispose with it:
68
+
69
+ ```ts
70
+ import { Effect, Exit } from "effect";
71
+
72
+ interface PendingComment {
73
+ readonly id: string;
74
+ readonly body: string;
75
+ readonly status: "sending" | "queued";
76
+ }
77
+
78
+ export const pendingCommentsAtom = Atom.family((_projectId: ProjectId) =>
79
+ Atom.make<ReadonlyArray<PendingComment>>([]),
80
+ );
81
+
82
+ export const sendCommentWithEcho = Atom.family((projectId: ProjectId) =>
83
+ Atom.fn<{ readonly body: string }>()(
84
+ Effect.fnUntraced(function* (input, get) {
85
+ const pending = pendingCommentsAtom(projectId);
86
+ const id = yield* Effect.sync(() => crypto.randomUUID());
87
+
88
+ get.set(pending, [...get(pending), { id, body: input.body, status: "sending" }]);
89
+
90
+ const exit = yield* Effect.exit(get.setResult(sendComment, { projectId, body: input.body }));
91
+
92
+ get.set(
93
+ pending,
94
+ Exit.isSuccess(exit)
95
+ ? get(pending).map((entry) =>
96
+ entry.id === id ? { ...entry, status: "queued" as const } : entry,
97
+ )
98
+ : get(pending).filter((entry) => entry.id !== id),
99
+ );
100
+
101
+ yield* exit;
102
+ }),
103
+ ),
104
+ );
105
+ ```
106
+
107
+ The pending list lives in an `Atom.family` state atom, not component
108
+ `useState`, so the workflow owns append, settle, and rollback while any
109
+ component can render it. Re-raise the exit so the dispatching leaf still
110
+ observes failure.
111
+
112
+ ## Overlay optimistic query values
113
+
114
+ When the optimistic value is the query's own value rather than a sidecar list,
115
+ wrap the query with `Atom.optimistic` and drive it with `Atom.optimisticFn`:
116
+
117
+ ```ts
118
+ import { AsyncResult } from "effect/unstable/reactivity";
119
+
120
+ export const timelineAtom = Atom.family((projectId: ProjectId) =>
121
+ Atom.optimistic(timelineQuery(projectId)),
122
+ );
123
+
124
+ export const sendMessage = Atom.family((projectId: ProjectId) =>
125
+ Atom.optimisticFn(timelineAtom(projectId), {
126
+ reducer: (current, message: MessageDto) =>
127
+ AsyncResult.map(current, (page) => ({
128
+ ...page,
129
+ messages: [...page.messages, message],
130
+ })),
131
+ fn: submitMessage(projectId),
132
+ }),
133
+ );
134
+ ```
135
+
136
+ The reducer computes the provisional value shown while the mutation runs; a
137
+ successful transition refreshes the source query, and a failure rolls the
138
+ value back to the latest source value. Consumers render `timelineAtom` and
139
+ never see the seam.
140
+
141
+ ## Dispatch from React
142
+
143
+ A promise-mode dispatch is handed bare to a leaf component whose contract is
144
+ promise-shaped; presentation derives from the atom's `AsyncResult`:
145
+
146
+ ```tsx
147
+ const [sendResult, send] = useAtom(sendMessage(projectId), { mode: "promise" });
148
+ const hint = AsyncResult.isSuccess(sendResult) && !sendResult.waiting ? "sent" : undefined;
149
+
150
+ return <Composer hint={hint} onSubmit={(body) => send({ body })} />;
151
+ ```
152
+
153
+ The returned promise resolves with the success value and rejects with the
154
+ squashed failure cause; use `mode: "promiseExit"` when the leaf needs the full
155
+ `Exit`. Anything more than returning the promise — chaining a refresh, echo
156
+ bookkeeping, sequencing a second mutation — belongs in the workflow atom.
157
+
158
+ ## Reinforce the boundary with a lint rule
159
+
160
+ A repository can back the logic-free boundary with a lint warning on `then`
161
+ scoped to component and route modules, mirroring the typed-codec lint pattern:
162
+
163
+ ```ts
164
+ {
165
+ files: ["apps/web/src/components/**", "apps/web/src/routes/**"],
166
+ rules: {
167
+ "no-restricted-properties": [
168
+ "warn",
169
+ {
170
+ property: "then",
171
+ message:
172
+ "Compose the workflow in Effect (Atom.fn + reactivity keys) and return promise-mode dispatches bare to the leaf component.",
173
+ },
174
+ ],
175
+ },
176
+ }
177
+ ```
178
+
179
+ Keep any justified suppression local and documented; the boundary reasoning,
180
+ not the lint rule, remains the source of truth.
@@ -12,6 +12,7 @@ import { printError } from "../cli-ui.ts";
12
12
  import { syncEffectSource } from "../effect-source.ts";
13
13
  import { patchEffectTsgo } from "../effect-tsgo.ts";
14
14
  import { patchProjectGitignore } from "../gitignore.ts";
15
+ import { CACHE_PRUNE_AGE_DAYS, runCachePrune } from "../global-cache.ts";
15
16
  import {
16
17
  addSkills,
17
18
  chooseSkillsToAdd,
@@ -351,6 +352,25 @@ const catalogVerifyCommand = CliCommand.make(
351
352
  refreshSkillCatalog({ locked: true, lockfilePath: lockfile, repoDir, sourcesPath: sources }),
352
353
  ).pipe(CliCommand.withDescription("Verify the committed catalog without advancing refs."));
353
354
 
355
+ const cachePruneCommand = CliCommand.make(
356
+ "prune",
357
+ {
358
+ all: Flag.boolean("all").pipe(
359
+ Flag.withDescription("Remove the entire cache instead of only stale content."),
360
+ ),
361
+ maxAgeDays: Flag.integer("max-age-days").pipe(
362
+ Flag.withDefault(CACHE_PRUNE_AGE_DAYS),
363
+ Flag.withDescription("Evict content unused for this many days."),
364
+ ),
365
+ },
366
+ ({ all, maxAgeDays }) => runCachePrune({ all, maxAgeDays }),
367
+ ).pipe(CliCommand.withDescription("Evict stale content from the machine-global source cache."));
368
+
369
+ const cacheCommand = CliCommand.make("cache").pipe(
370
+ CliCommand.withDescription("Manage the machine-global source cache."),
371
+ CliCommand.withSubcommands([cachePruneCommand] as const),
372
+ );
373
+
354
374
  const catalogCommand = CliCommand.make("catalog").pipe(
355
375
  CliCommand.withDescription("Maintain the approved upstream catalog."),
356
376
  CliCommand.withSubcommands([
@@ -382,6 +402,7 @@ const command = CliCommand.make("dev-kit", projectFlags, ({ manifest, projectDir
382
402
  effectCommand,
383
403
  tsgoCommand,
384
404
  catalogCommand,
405
+ cacheCommand,
385
406
  ],
386
407
  },
387
408
  ] as const),
package/src/catalog.ts CHANGED
@@ -2,6 +2,11 @@ import { Effect, FileSystem, Path, Schema, Stream } from "effect";
2
2
  import { ChildProcess } from "effect/unstable/process";
3
3
  import { parse as parseJsonc, type ParseError } from "jsonc-parser";
4
4
 
5
+ import {
6
+ commitCacheDirectory,
7
+ resolveGlobalCacheDirectory,
8
+ stampCacheEntryUsage,
9
+ } from "./global-cache.ts";
5
10
  import {
6
11
  discoverPackageSkills,
7
12
  resolvePackageSkillSelector,
@@ -181,7 +186,13 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
181
186
  });
182
187
  }
183
188
  const families: Readonly<Record<string, ReadonlyArray<string>>> = {
184
- effect: ["effect-ts", "effect-architecture-audit", "build-effect-apis", "build-effect-clis"],
189
+ effect: [
190
+ "effect-ts",
191
+ "effect-architecture-audit",
192
+ "build-effect-apis",
193
+ "effect-atom-state",
194
+ "build-effect-clis",
195
+ ],
185
196
  ...Object.fromEntries(externalFamilies),
186
197
  };
187
198
 
@@ -261,26 +272,35 @@ const materializePackageSkill = Effect.fn("materializePackageSkill")(function* (
261
272
  return staged;
262
273
  });
263
274
 
275
+ // Catalog checkouts are keyed by source id and resolved commit SHA, so the
276
+ // materialized content is immutable and shared machine-wide across projects
277
+ // and worktrees. Planning and locked verification populate the same cache:
278
+ // writing an immutable commit-keyed cache entry is not project state.
264
279
  const materializeSource = Effect.fn("materializeCatalogSource")(function* (
265
- projectDir: string,
266
280
  source: LockedSkillSource,
267
281
  selected: ReadonlyArray<string>,
268
- cache: boolean,
269
282
  ) {
270
283
  const fs = yield* FileSystem.FileSystem;
271
284
  const path = yield* Path.Path;
272
- const root = cache
273
- ? path.join(projectDir, ".dev-kit", "cache", "catalog", source.id, source.resolved)
274
- : path.join(
275
- yield* fs.makeTempDirectoryScoped({ prefix: "dev-kit-catalog-plan-" }),
276
- source.id,
277
- source.resolved,
278
- );
279
- const checkout = path.join(root, "checkout");
285
+ const root = path.join(
286
+ yield* resolveGlobalCacheDirectory(),
287
+ "catalog",
288
+ source.id,
289
+ source.resolved,
290
+ );
280
291
  const ready = path.join(root, ".ready");
281
292
 
282
293
  if (!(yield* fs.exists(ready))) {
283
- yield* fs.remove(root, { force: true, recursive: true });
294
+ yield* fs.makeDirectory(path.dirname(root), { recursive: true });
295
+ const staged = path.join(
296
+ yield* fs.makeTempDirectoryScoped({
297
+ directory: path.dirname(root),
298
+ prefix: ".dev-kit-catalog-stage-",
299
+ }),
300
+ source.resolved,
301
+ );
302
+ const checkout = path.join(staged, "checkout");
303
+
284
304
  yield* fs.makeDirectory(checkout, { recursive: true });
285
305
  yield* runGit(checkout, ["init", "--quiet"]);
286
306
  yield* runGit(checkout, ["remote", "add", "origin", source.repository]);
@@ -302,7 +322,7 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
302
322
  }
303
323
  for (const skill of source.skills) {
304
324
  const from = path.join(checkout, source.skillsPath, skill);
305
- const to = path.join(root, "skills", skill);
325
+ const to = path.join(staged, "skills", skill);
306
326
  const observation = yield* observePath(from);
307
327
 
308
328
  if (observation.kind !== "directory") {
@@ -320,8 +340,10 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
320
340
  );
321
341
  }
322
342
  }
323
- yield* fs.writeFileString(ready, `${source.resolved}\n`);
343
+ yield* fs.writeFileString(path.join(staged, ".ready"), `${source.resolved}\n`);
344
+ yield* commitCacheDirectory(staged, root, fs.exists(ready));
324
345
  }
346
+ yield* stampCacheEntryUsage(root);
325
347
  for (const skill of selected) {
326
348
  const observation = yield* observePath(path.join(root, "skills", skill));
327
349
  const approvedDigest = source.digests?.[skill];
@@ -351,6 +373,8 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
351
373
  );
352
374
  });
353
375
 
376
+ // The cache flag only affects package skills, whose staging area is project
377
+ // state under .dev-kit; catalog sources always use the machine-global cache.
354
378
  export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
355
379
  packageRoot: string,
356
380
  projectDir: string,
@@ -370,7 +394,7 @@ export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
370
394
  const wanted = source.skills.filter((skill) => selected.includes(skill));
371
395
 
372
396
  if (wanted.length === 0) continue;
373
- for (const [name, sourcePath] of yield* materializeSource(projectDir, source, wanted, cache)) {
397
+ for (const [name, sourcePath] of yield* materializeSource(source, wanted)) {
374
398
  sources.set(name, sourcePath);
375
399
  }
376
400
  }