@danieljvdm/dev-kit 0.5.0 → 0.6.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
@@ -73,18 +73,33 @@ bun x dev-kit plan
73
73
  bun x dev-kit apply
74
74
  ```
75
75
 
76
- Commit the generated `dev-kit.lock.json`, then use locked mode in your package
77
- lifecycle:
76
+ Commit the generated `dev-kit.lock.json`, then let the package lifecycle
77
+ converge owned outputs automatically when installed packages change:
78
78
 
79
79
  ```jsonc
80
80
  {
81
81
  "scripts": {
82
- "postinstall": "dev-kit apply --locked"
82
+ "postinstall": "dev-kit apply"
83
83
  }
84
84
  }
85
85
  ```
86
86
 
87
- That single postinstall applies every task enabled in `dev-kit.jsonc`.
87
+ That single postinstall applies every task enabled in `dev-kit.jsonc` and
88
+ regenerates `dev-kit.lock.json` when an intentional package upgrade changes a
89
+ bundled or package-provided skill. Ownership and conflict checks still prevent
90
+ unreviewed overwrites.
91
+
92
+ Keep strict verification in CI. Either install with lifecycle scripts disabled
93
+ before running locked mode:
94
+
95
+ ```bash
96
+ bun install --ignore-scripts
97
+ bun x dev-kit apply --locked
98
+ ```
99
+
100
+ Or allow the normal postinstall and fail CI when it leaves tracked changes.
101
+ Do not run an unlocked apply before a locked verification because that would
102
+ regenerate the drift being checked.
88
103
 
89
104
  This repository dogfoods the same flow with its committed `dev-kit.jsonc` and
90
105
  `dev-kit.lock.json`. From this source checkout, invoke the local CLI with:
@@ -166,6 +181,7 @@ tool versions. A project-local process lock also prevents concurrent applies.
166
181
  ],
167
182
  "exclude": ["animation-vocabulary"],
168
183
  "setup": {
184
+ "agentInstructions": { "enabled": true },
169
185
  "claudeInstructions": { "enabled": true }
170
186
  },
171
187
  "targets": {
@@ -177,7 +193,8 @@ tool versions. A project-local process lock also prevents concurrent applies.
177
193
  ```
178
194
 
179
195
  - `dev-kit` installs guidance for operating the toolkit itself.
180
- - `effect` expands to the consolidated `effect-ts` skill.
196
+ - `effect` expands to `effect-ts` plus focused Effect Atom HTTP data-fetching
197
+ guidance.
181
198
  - Prefer individual external skills such as `workers-best-practices` and
182
199
  `wrangler`, selected after scanning the project for relevant technologies.
183
200
  - `serve-sim` selects the approved Evan Bacon simulator skill directly.
@@ -191,24 +208,33 @@ Dev Kit reserves `.repos/<source-id>` for project-local source checkouts. Run
191
208
  The patch is idempotent, preserves existing lines, and refuses symlinked
192
209
  `.gitignore` files.
193
210
 
194
- ## Claude instructions
211
+ ## Agent instructions
195
212
 
196
- Enable a portable Claude Code instruction bridge in the manifest:
213
+ Enable a managed project-root instruction wrapper and a portable Claude Code
214
+ bridge in the manifest:
197
215
 
198
216
  ```jsonc
199
217
  {
200
- "include": [],
218
+ "include": ["dev-kit"],
201
219
  "setup": {
220
+ "agentInstructions": { "enabled": true },
202
221
  "claudeInstructions": { "enabled": true }
203
222
  }
204
223
  }
205
224
  ```
206
225
 
207
- `dev-kit apply` requires a project-root `AGENTS.md`, then manages
208
- `CLAUDE.md` as the relative symlink `CLAUDE.md AGENTS.md`. The link is
209
- recorded in the lockfile and local ownership state. Dev Kit refuses to replace
210
- an unowned `CLAUDE.md` and removes the link when the task is disabled only if
211
- the owned link is unchanged.
226
+ `setup.agentInstructions` manages `AGENTS.md` as a generated wrapper with a
227
+ short description of dev-kit and a pointer to the installed `dev-kit` skill.
228
+ When the root `package.json` declares `vite-plus` directly, the wrapper also
229
+ includes Vite+'s installed `node_modules/vite-plus/AGENTS.md` instructions.
230
+ Transitive installations do not opt a project in.
231
+
232
+ `setup.claudeInstructions` manages `CLAUDE.md` as the relative symlink
233
+ `CLAUDE.md → AGENTS.md`. It can link to the generated wrapper in the same apply,
234
+ or retain the older behavior of linking to an existing regular `AGENTS.md` when
235
+ the wrapper task is disabled. Both outputs are recorded independently in the
236
+ lockfile and local ownership state. Dev Kit refuses to replace unowned files
237
+ and removes only unchanged owned outputs.
212
238
 
213
239
  ## Effect source checkout
214
240
 
@@ -2,6 +2,7 @@
2
2
  "$schema": "./schema/dev-kit.schema.json",
3
3
  "include": ["dev-kit", "effect"],
4
4
  "setup": {
5
+ "agentInstructions": { "enabled": true },
5
6
  "claudeInstructions": { "enabled": true },
6
7
  "effectSource": { "enabled": true },
7
8
  "effectTsgo": { "enabled": true }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieljvdm/dev-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Declarative project development toolkit with portable agent skills.",
@@ -40,7 +40,8 @@
40
40
  "skills/",
41
41
  "skill-sources.jsonc",
42
42
  "skill-sources.lock.json",
43
- "src/"
43
+ "src/",
44
+ "templates/"
44
45
  ],
45
46
  "scripts": {
46
47
  "prepare": "./bin/dev-kit.mjs apply --locked",
@@ -34,6 +34,9 @@
34
34
  "type": "object",
35
35
  "additionalProperties": false,
36
36
  "properties": {
37
+ "agentInstructions": {
38
+ "$ref": "#/$defs/agentInstructionsSetup"
39
+ },
37
40
  "claudeInstructions": {
38
41
  "$ref": "#/$defs/claudeInstructionsSetup"
39
42
  },
@@ -61,6 +64,17 @@
61
64
  },
62
65
  "required": ["include"],
63
66
  "$defs": {
67
+ "agentInstructionsSetup": {
68
+ "description": "Manage a project-root AGENTS.md wrapper with dev-kit guidance and conditional tool instructions.",
69
+ "type": "object",
70
+ "additionalProperties": false,
71
+ "properties": {
72
+ "enabled": {
73
+ "type": "boolean",
74
+ "default": false
75
+ }
76
+ }
77
+ },
64
78
  "claudeInstructionsSetup": {
65
79
  "description": "Manage CLAUDE.md as a relative symlink to the project-root AGENTS.md file.",
66
80
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dev-kit
3
- description: Dev-kit operations for projects that configure dev-kit.jsonc, sync portable skills, run plan/apply or locked postinstalls, maintain dev-kit.lock.json, resolve ownership conflicts, patch managed ignores, or enable Effect TypeScript-Go.
3
+ description: Dev-kit operations for projects that configure dev-kit.jsonc, sync portable skills, run plan/apply or automatic postinstalls, perform locked CI checks, maintain dev-kit.lock.json, resolve ownership conflicts, patch managed ignores, or enable Effect TypeScript-Go.
4
4
  ---
5
5
 
6
6
  # Dev Kit
@@ -51,8 +51,12 @@ stores explicit skill names and exact commit/content digests.
51
51
  6. Resolve conflicts, then run `dev-kit apply`. Commit the manifest and
52
52
  regenerated `dev-kit.lock.json`; keep `.dev-kit/` local. Finish when a second
53
53
  plan reports only unchanged resources and setup tasks.
54
- 7. Use `dev-kit apply --locked` in CI and the package lifecycle. Finish when a
55
- clean install converges from the committed manifest and lock.
54
+ 7. Use `dev-kit apply` in the package lifecycle so intentional dependency
55
+ upgrades regenerate owned outputs and `dev-kit.lock.json`. For strict CI,
56
+ either disable lifecycle scripts before `dev-kit apply --locked`, or run the
57
+ normal lifecycle and require the tracked working tree to remain clean. Never
58
+ run an unlocked apply before locked verification. Finish when a clean install
59
+ converges from the committed manifest and lock.
56
60
 
57
61
  ## Manifest
58
62
 
@@ -68,6 +72,7 @@ skill as `dev-kit` when project agents should carry the toolkit procedure.
68
72
  "include": ["dev-kit", "effect"],
69
73
  "exclude": [],
70
74
  "setup": {
75
+ "agentInstructions": { "enabled": true },
71
76
  "claudeInstructions": { "enabled": true }
72
77
  },
73
78
  "targets": {
@@ -83,11 +88,14 @@ use symlinks for additional harness discovery paths. Keep every target path
83
88
  project-relative and separate from the manifest, lock, state, and process-lock
84
89
  paths.
85
90
 
91
+ Enable `setup.agentInstructions` to manage a project-root `AGENTS.md` wrapper
92
+ that points agents back to this skill. When `vite-plus` is a declared direct
93
+ dependency, dev-kit includes its installed agent instructions in the wrapper.
86
94
  Enable `setup.claudeInstructions` when Claude Code should consume the same
87
- project-root instructions as Codex. It manages `CLAUDE.md` as a relative
88
- symlink to an existing `AGENTS.md`. Preserve any conflicting `CLAUDE.md`; when
89
- disabled, dev-kit removes only an unchanged link recorded in local ownership
90
- state.
95
+ project-root instructions; it manages `CLAUDE.md` as a relative symlink to the
96
+ wrapper or to an existing regular `AGENTS.md`. Preserve conflicting paths;
97
+ when disabled, dev-kit removes only unchanged outputs recorded in local
98
+ ownership state.
91
99
 
92
100
  ## Ownership and conflicts
93
101
 
@@ -118,11 +126,17 @@ For one lifecycle entry point, configure:
118
126
  ```jsonc
119
127
  {
120
128
  "scripts": {
121
- "postinstall": "dev-kit apply --locked"
129
+ "postinstall": "dev-kit apply"
122
130
  }
123
131
  }
124
132
  ```
125
133
 
134
+ This intentionally refreshes the committed lock and owned outputs when the
135
+ package manager installs a new Dev Kit or selected package-skill version.
136
+ Review and commit those changes with the dependency update. Keep
137
+ `dev-kit apply --locked` as a verification command, not the normal local
138
+ lifecycle; in CI, run it only before any unlocked apply.
139
+
126
140
  ## Effect source checkout
127
141
 
128
142
  Enable the source task when agents should have canonical source matching the
@@ -204,7 +218,8 @@ in the consuming project.
204
218
 
205
219
  ## Current boundary
206
220
 
207
- Manage skill outputs, the `setup.claudeInstructions` link, the
221
+ Manage skill outputs, the `setup.agentInstructions` wrapper, the
222
+ `setup.claudeInstructions` link, the
208
223
  `setup.effectSource` checkout, and the explicit `setup.effectTsgo` task. Edit
209
224
  shared `package.json` and `tsconfig.json`
210
225
  contributions deliberately. The Oxlint and Oxfmt configurations are composable
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: effect-atom-data-fetching
3
+ description: Design, implement, debug, or review HTTP data fetching with Effect Atom in React applications. Use for AtomHttpApi.Service queries and mutations, parameterized Atom.family caches, RegistryProvider and runtime placement, TTL/SWR/polling behavior, reactivity-key invalidation, framework-appropriate SSR and focus handling, AsyncResult aggregation resets, and deterministic cache lifecycle tests.
4
+ ---
5
+
6
+ # Effect Atom Data Fetching
7
+
8
+ Model server data as stable atoms owned outside React renders. Give the application one intentional registry boundary and one compatible runtime factory; then choose cache retention, freshness, polling, and invalidation independently.
9
+
10
+ ## Workflow
11
+
12
+ 1. Inspect the installed `effect` and `@effect/atom-react` versions and their source before copying signatures. These APIs live under `effect/unstable/reactivity` and may move.
13
+ 2. Locate every `RegistryProvider`, runtime factory, query atom, mutation atom, and route-level `AsyncResult.all`. Draw the ownership boundary before changing behavior.
14
+ 3. Keep one `RegistryProvider` for the intended client application lifetime. Define the shared runtime factory, API service, query families, and mutation atoms at module scope rather than in components.
15
+ 4. Choose each lifecycle control for its actual job:
16
+ - idle `timeToLive`: retain an unused registry value before disposal;
17
+ - `Atom.swr({ staleTime })`: decide when mount/focus revalidation is needed;
18
+ - `Atom.withRefresh`: force periodic refresh while mounted.
19
+ 5. Give queries stable identities and matching reactivity keys. Let successful mutations invalidate those keys rather than manually coordinating every consumer.
20
+ 6. Keep browser-only signals behind an SSR-safe boundary. Decide explicitly whether initial data is client-only or hydrated from a request-scoped server registry.
21
+ 7. Verify lifecycle behavior with fake time and request counters, not sleeps.
22
+
23
+ ## Ownership rules
24
+
25
+ - Treat a query as shared read state: export one atom or `Atom.family` and let components subscribe.
26
+ - Treat an action as an event owned by the initiating UI or workflow: export the mutation atom, invoke it with `useAtomSet`, and observe its result only where useful.
27
+ - Never allocate a query atom in render. For parameterized queries, use a stable scalar or Effect `Hash`/`Equal` value as the family argument.
28
+ - Ensure every input to a route-level `AsyncResult.all` has stable atom identity and compatible retention. One newly allocated or immediately evicted input returns to `Initial` and makes the whole aggregate appear reset even when the other inputs remain cached.
29
+ - Do not describe manual refresh or polling as freshness caching. Refresh is forceful; `staleTime` only gates SWR's automatic mount/focus decisions.
30
+
31
+ ## References
32
+
33
+ - Read `./references/cache-lifecycle.md` for registry scope, runtime memoization, families, TTL, SWR, polling, and the aggregation trap.
34
+ - Read `./references/http-and-invalidation.md` for `AtomHttpApi.Service`, query/mutation ownership, reactivity-key vocabulary, and invalidation.
35
+ - Read `./references/tanstack-start.md` only for its framework-specific provider placement, SSR isolation, hydration, and focus guidance.
36
+ - Read `./references/testing.md` when adding or diagnosing lifecycle tests.
37
+
38
+ ## Completion check
39
+
40
+ Confirm one registry boundary, stable atom identity, deliberate TTL/staleness/polling values, matching query and mutation keys, SSR-safe browser access, and tests for every lifecycle behavior changed.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Effect Atom Data Fetching"
3
+ short_description: "Cache HTTP data safely with Effect Atom"
4
+ default_prompt: "Use $effect-atom-data-fetching to design or debug Effect Atom HTTP queries, caching, polling, invalidation, and SSR integration in a React app."
@@ -0,0 +1,72 @@
1
+ # Cache lifecycle
2
+
3
+ ## Registry and runtime scope
4
+
5
+ `RegistryProvider` creates one `AtomRegistry` on its first render. Its options do not rebuild that registry later. Provider unmount schedules disposal after a short grace period, so a quick React remount can reuse the same registry; moving or keying the provider still changes the cache boundary.
6
+
7
+ Place one provider around the client application subtree that should share data. Nested or route-local providers create separate caches.
8
+
9
+ An atom runtime and a registry solve different problems:
10
+
11
+ - the registry stores atom nodes, values, subscriptions, idle timers, and finalizers;
12
+ - `Atom.context({ memoMap })` creates runtimes that share `Layer` construction through one `Layer.MemoMap`;
13
+ - the module-level `Atom.runtime` uses Effect's module-level default memo map.
14
+
15
+ Create one client runtime factory when several API/services must share layers:
16
+
17
+ ```ts
18
+ import { Layer } from "effect";
19
+ import { Atom } from "effect/unstable/reactivity";
20
+
21
+ export const appAtomRuntime = Atom.context({
22
+ memoMap: Layer.makeMemoMapUnsafe(),
23
+ });
24
+ ```
25
+
26
+ Pass `appAtomRuntime` to each `AtomHttpApi.Service`. Do not create a memo map per query or component. On an SSR server, do not put request-specific authentication or services into a process-global memo map; use a request-scoped atom environment or keep the atom data path client-only.
27
+
28
+ ## Stable identity and families
29
+
30
+ Export fixed queries directly. Use `Atom.family` when a parameter selects the resource:
31
+
32
+ ```ts
33
+ export const projectAtom = Atom.family((projectId: string) =>
34
+ ApiClient.query("projects", "get", {
35
+ params: { projectId },
36
+ timeToLive: "5 minutes",
37
+ reactivityKeys: { projects: [projectId] },
38
+ }).pipe(Atom.swr({ staleTime: "30 seconds", revalidateOnMount: true })),
39
+ );
40
+ ```
41
+
42
+ The family must receive a stable key. Prefer a primitive ID. If the key is an object, give it deliberate Effect `Equal`/`Hash` semantics or reuse the same object; repeated object literals can produce distinct family entries.
43
+
44
+ ## Three independent clocks
45
+
46
+ | Control | Clock starts | What happens | What it does not mean |
47
+ | ----------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
48
+ | Registry/default idle TTL or query `timeToLive` | When an atom becomes unused | The registry keeps the cached node until idle eviction | The value is fresh during that period |
49
+ | `Atom.swr({ staleTime })` | From the latest success timestamp | A stale value can revalidate automatically on mount or focus while the prior success remains visible | The node survives an unmount long enough to be reused |
50
+ | `Atom.withRefresh(interval)` | While the wrapper is mounted | A timer force-refreshes the source and is canceled on disposal | Fresh requests are skipped |
51
+
52
+ Set retention long enough for the navigation/remount reuse window. `staleTime` cannot rescue a source that idle eviction already removed. A common policy is a longer `timeToLive`, a shorter `staleTime`, and polling only on screens that truly need it.
53
+
54
+ Manual `registry.refresh`, `useAtomRefresh`, invalidation, and `Atom.withRefresh` are forceful. They do not consult SWR freshness. Polling stops when the polling wrapper's lifetime is disposed because its finalizer clears the timer; applying `keepAlive` to that wrapper intentionally keeps polling alive.
55
+
56
+ ## The `AsyncResult.all` route reset
57
+
58
+ `AsyncResult.all` returns the first non-success input. Therefore a route aggregate is only as reusable as its least-stable input:
59
+
60
+ ```ts
61
+ const routeDataAtom = Atom.make((get) =>
62
+ AsyncResult.all({
63
+ project: get(projectAtom("p-1")),
64
+ // Bad if created during render or rebuilt for every route visit:
65
+ preferences: get(makePreferencesAtom()),
66
+ }),
67
+ );
68
+ ```
69
+
70
+ If `preferences` is a fresh or evicted atom, it starts at `Initial`; the aggregate also looks initial even though `project` is cached. Fix the input's ownership and retention. Define a singleton/family atom outside render and give it a deliberate idle TTL. Memoizing only the `AsyncResult.all` call does not repair an unstable input atom.
71
+
72
+ `AsyncResult.all` also constructs a new success container. Keep aggregation inside a derived atom so the registry controls recomputation instead of rebuilding the container ad hoc in render.
@@ -0,0 +1,93 @@
1
+ # HTTP queries and invalidation
2
+
3
+ ## Build one API service
4
+
5
+ `AtomHttpApi.Service` generates a typed client, a runtime, query atoms, and mutation functions. Pass the shared runtime factory so API services share the intended `Layer.MemoMap`:
6
+
7
+ ```ts
8
+ import { FetchHttpClient } from "effect/unstable/http";
9
+ import { AtomHttpApi } from "effect/unstable/reactivity";
10
+ import { appAtomRuntime } from "./atom-runtime";
11
+ import { Api } from "./api";
12
+
13
+ export const ApiClient = AtomHttpApi.Service()("ApiClient", {
14
+ api: Api,
15
+ httpClient: FetchHttpClient.layer,
16
+ baseUrl: "/api",
17
+ runtime: appAtomRuntime,
18
+ });
19
+ ```
20
+
21
+ Compile examples against the installed Effect version; the reactivity and HTTP APIs are unstable.
22
+
23
+ ## Queries
24
+
25
+ `query(group, endpoint, request)` returns an `Atom<AsyncResult<...>>`. The service internally memoizes request keys with a family. A public `Atom.family` remains useful for expressing domain ownership with a simple, stable parameter and applying one cache policy.
26
+
27
+ Query options have separate roles:
28
+
29
+ - `timeToLive`: finite values apply idle TTL; infinity keeps the query alive;
30
+ - `reactivityKeys`: register the query for refresh after matching invalidation;
31
+ - `serializationKey`: make decoded-only results serializable for hydration; it is not the runtime cache key;
32
+ - `responseMode`: changes the response and error shape.
33
+
34
+ Never place secrets in `serializationKey`, URL state, hydration payloads, or client-visible layers.
35
+
36
+ ## Mutations and action ownership
37
+
38
+ Create mutation atoms once, then invoke them from the component or workflow that owns the action:
39
+
40
+ ```ts
41
+ export const updateProject = ApiClient.mutation("projects", "update");
42
+
43
+ // In the initiating component:
44
+ const mutate = useAtomSet(updateProject, { mode: "promise" });
45
+ await mutate({
46
+ params: { projectId },
47
+ payload: patch,
48
+ reactivityKeys: { projects: [projectId] },
49
+ });
50
+ ```
51
+
52
+ The mutation's `reactivityKeys` are invalidated only after the request succeeds. Failed mutations do not invalidate. Keep navigation, toasts, dialog closure, and optimistic UI at the action owner; keep shared server-state refresh in reactivity keys.
53
+
54
+ ## Use one key vocabulary
55
+
56
+ Array keys represent independent keys. Record keys support hierarchical broad-plus-entity invalidation:
57
+
58
+ ```ts
59
+ const listKeys = { projects: [] };
60
+ const detailKeys = { projects: [projectId] };
61
+
62
+ ApiClient.query("projects", "list", { reactivityKeys: listKeys });
63
+ ApiClient.query("projects", "get", { params: { projectId }, reactivityKeys: detailKeys });
64
+
65
+ // This mutation invalidates the broad namespace and this entity key.
66
+ await mutate({ params: { projectId }, payload: patch, reactivityKeys: detailKeys });
67
+ ```
68
+
69
+ Record semantics register the property name and each `property:id` combination. Consequently, `{ projects: [projectId] }` is hierarchical: it invalidates both the broad `projects` namespace and the specific `projects:projectId` key. Every record-form project query also subscribed to that broad namespace can refresh. Use this when an entity write may affect lists or aggregates.
70
+
71
+ For exact entity-only invalidation, use namespaced primitive array keys consistently instead:
72
+
73
+ ```ts
74
+ const projectKey = (id: string) => `project:${id}`;
75
+
76
+ const detailKeys = [projectKey(projectId)];
77
+ const collectionKeys = ["projects"];
78
+
79
+ ApiClient.query("projects", "get", { params: { projectId }, reactivityKeys: detailKeys });
80
+ await mutate({ params: { projectId }, payload: patch, reactivityKeys: detailKeys });
81
+ ```
82
+
83
+ Use `collectionKeys` for the collection query and for mutations that can change membership or ordering.
84
+
85
+ Standardize key constructors in one module when several endpoints share them; mismatched strings fail silently.
86
+
87
+ Choose invalidation breadth from the server write:
88
+
89
+ - invalidate an exact array-form entity key when only one detail changed;
90
+ - invalidate the collection key, or use hierarchical record keys, when list membership, ordering, totals, or filters can change;
91
+ - invalidate multiple record properties when a write affects related aggregates.
92
+
93
+ Do not both invalidate and manually refresh the same query unless two requests are intentional.
@@ -0,0 +1,69 @@
1
+ # TanStack Start integration
2
+
3
+ TanStack Start code is isomorphic by default, including route loaders. Treat every module used by a route as server-capable unless an explicit boundary says otherwise.
4
+
5
+ ## Choose an SSR strategy
6
+
7
+ Use one of these models deliberately:
8
+
9
+ ### Client-only atom data
10
+
11
+ - Put one `RegistryProvider` in the app/root component so client navigations share a registry.
12
+ - Render atom consumers that touch browser-only APIs inside `ClientOnly` from `@tanstack/react-router`.
13
+ - Accept that the fallback is the server-rendered state and fetching begins on the client.
14
+ - Keep the client runtime and API service as module singletons.
15
+
16
+ ```tsx
17
+ import { RegistryProvider } from "@effect/atom-react";
18
+ import { ClientOnly } from "@tanstack/react-router";
19
+
20
+ export function AppShell() {
21
+ return (
22
+ <RegistryProvider defaultIdleTTL={30_000}>
23
+ <ClientOnly fallback={<DashboardSkeleton />}>
24
+ <Dashboard />
25
+ </ClientOnly>
26
+ </RegistryProvider>
27
+ );
28
+ }
29
+ ```
30
+
31
+ ### SSR plus hydration
32
+
33
+ - Create the registry and any request-specific runtime/layers per request; never share user/auth state through a process-global registry or memo map.
34
+ - Give decoded query atoms deterministic `serializationKey` values.
35
+ - Mount/run the required serializable atoms on the server, dehydrate only the intended values, and pass them through the document safely.
36
+ - Create the client registry once, then hydrate matching atom identities before descendants consume them. Use `HydrationBoundary` where the installed React adapter supports it.
37
+ - Verify that server and client construct the same API service, family arguments, and serialization keys.
38
+
39
+ Prefer framework loaders/server functions when they already own SSR data. Do not build a second atom SSR cache merely to mirror loader data; seed atoms from the loader or keep atom fetching client-only.
40
+
41
+ ## Focus is browser-only
42
+
43
+ `Atom.windowFocusSignal` reads `window` and `document.visibilityState` when mounted. Do not mount it during SSR. Put focus-enabled consumers behind `ClientOnly`, or inject a no-op server signal and the browser signal on the client.
44
+
45
+ ```ts
46
+ const project = projectAtom(projectId).pipe(
47
+ Atom.swr({
48
+ staleTime: "30 seconds",
49
+ revalidateOnFocus: true,
50
+ focusSignal: Atom.windowFocusSignal,
51
+ }),
52
+ );
53
+ ```
54
+
55
+ `revalidateOnFocus: true` respects `staleTime`; `"always"` forces a request on every focus signal.
56
+
57
+ ## In-memory limits
58
+
59
+ Effect Atom's registry cache is in memory and scoped to that registry:
60
+
61
+ - a new tab, hard reload, server process, or newly created provider starts another cache;
62
+ - idle TTL evicts only unused atoms and is not a maximum-entry or byte-size bound;
63
+ - `keepAlive` and infinite TTL can grow memory with unbounded family keys;
64
+ - browser memory is not durable or shared across users/devices;
65
+ - hydration transfers a snapshot, not a persistent distributed cache.
66
+
67
+ For large or unbounded parameter spaces, use finite TTLs and avoid `keepAlive`. Put durable/shared caching at the HTTP, server, CDN, or database layer.
68
+
69
+ Primary TanStack references: [execution model](https://tanstack.com/start/latest/docs/framework/react/guide/execution-model) and [`ClientOnly`](https://tanstack.com/router/latest/docs/api/router/clientOnlyComponent).
@@ -0,0 +1,63 @@
1
+ # Deterministic lifecycle testing
2
+
3
+ Test cache policy below React first with `AtomRegistry.make()`. Add a React integration test only for provider placement, hook behavior, a browser-only SSR boundary, or hydration.
4
+
5
+ Use fake timers, a request counter, controllable Effects, and explicit mounts:
6
+
7
+ ```ts
8
+ const registry = AtomRegistry.make({ defaultIdleTTL: 1_000 });
9
+ const unmount = registry.mount(queryAtom);
10
+ const first = registry.get(queryAtom);
11
+
12
+ // Advance the Effect scheduler/microtasks as required by the installed version.
13
+ // Assert with AsyncResult predicates and request counts.
14
+
15
+ unmount();
16
+ ```
17
+
18
+ Avoid wall-clock sleeps. Flush Effect work with the repository's established `Effect.yieldNow`/test-clock pattern and advance the test runner's fake timers.
19
+
20
+ Prefer the installed test APIs over invented helpers or matchers. The upstream Effect tests use `assert(AsyncResult.isSuccess(result))`, `Effect.runPromise(Effect.yieldNow)`, `vitest.advanceTimersByTimeAsync(...)`, and the cleanup returned by `registry.mount(atom)`.
21
+
22
+ ## Required scenarios
23
+
24
+ ### Remount reuse
25
+
26
+ 1. Mount and resolve the query; assert request count `1`.
27
+ 2. Unmount, advance less than idle TTL, remount the same atom identity.
28
+ 3. Assert the cached success is immediately available and no request occurs while still fresh.
29
+
30
+ ### Stale refresh
31
+
32
+ 1. Resolve once through an SWR wrapper.
33
+ 2. Advance past `staleTime` but not idle TTL.
34
+ 3. Remount or emit the injected focus signal.
35
+ 4. Assert the previous success stays available with `waiting: true`, then a second success arrives and request count becomes `2`.
36
+ 5. Also prove a fresh mount/focus does not request.
37
+
38
+ ### TTL eviction
39
+
40
+ 1. Resolve and unmount.
41
+ 2. Advance to just before TTL; assert reuse.
42
+ 3. Advance to/after TTL and flush disposal; remount.
43
+ 4. Assert `Initial`/waiting behavior and a new request.
44
+
45
+ ### Polling cleanup
46
+
47
+ 1. Mount the `Atom.withRefresh` wrapper and resolve once.
48
+ 2. Advance one interval; assert one forced refresh.
49
+ 3. Unmount and advance several intervals.
50
+ 4. Assert the counter does not change. This catches leaked timers or accidental `keepAlive`.
51
+
52
+ ### Mutation invalidation
53
+
54
+ 1. Mount list and detail queries with explicit keys.
55
+ 2. Run a successful mutation with matching keys; assert only the intended queries refresh.
56
+ 3. Run a failed mutation; assert no invalidation.
57
+ 4. Assert cleanup removes invalidation handlers after query disposal.
58
+
59
+ ### Aggregate stability
60
+
61
+ Mount a route atom that uses `AsyncResult.all`, resolve every input, unmount, and remount inside the retention window. Assert the aggregate never returns to `Initial`. Then let one input expire and prove the aggregate reset is caused by that input, not the retained queries.
62
+
63
+ For runtime/layer tests, seed `runtime.layer` through `RegistryProvider initialValues` with a deterministic test layer. This replaces network services without changing the production atom graph.
package/src/catalog.ts CHANGED
@@ -167,7 +167,7 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
167
167
  });
168
168
  }
169
169
  const families: Readonly<Record<string, ReadonlyArray<string>>> = {
170
- effect: ["effect-ts"],
170
+ effect: ["effect-ts", "effect-atom-data-fetching"],
171
171
  ...Object.fromEntries(externalFamilies),
172
172
  };
173
173
  return {
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export {
2
+ type AgentInstructionsSetup,
3
+ AgentInstructionsSetupSchema,
2
4
  type ClaudeInstructionsSetup,
3
5
  ClaudeInstructionsSetupSchema,
4
6
  type DevKitManifest,
@@ -64,6 +66,8 @@ export {
64
66
  DevKitLockSchema,
65
67
  EffectSourceLockSchema,
66
68
  EffectTsgoLockSchema,
69
+ ManagedAgentInstructionsOutputSchema,
70
+ ManagedClaudeInstructionsOutputSchema,
67
71
  ManagedInstructionOutputSchema,
68
72
  ManagedOutputSchema,
69
73
  ManagedSkillOutputSchema,
@@ -73,6 +77,8 @@ export {
73
77
  type DevKitLock,
74
78
  type EffectSourceLock,
75
79
  type EffectTsgoLock,
80
+ type ManagedAgentInstructionsOutput,
81
+ type ManagedClaudeInstructionsOutput,
76
82
  type ManagedInstructionOutput,
77
83
  type ManagedOutput,
78
84
  type ManagedSkillOutput,
package/src/manifest.ts CHANGED
@@ -37,6 +37,12 @@ export const EffectSourceSetupSchema = Schema.Struct({
37
37
 
38
38
  export type EffectSourceSetup = typeof EffectSourceSetupSchema.Type;
39
39
 
40
+ export const AgentInstructionsSetupSchema = Schema.Struct({
41
+ enabled: Schema.optional(Schema.Boolean),
42
+ });
43
+
44
+ export type AgentInstructionsSetup = typeof AgentInstructionsSetupSchema.Type;
45
+
40
46
  export const ClaudeInstructionsSetupSchema = Schema.Struct({
41
47
  enabled: Schema.optional(Schema.Boolean),
42
48
  });
@@ -49,6 +55,7 @@ export const DevKitManifestSchema = Schema.Struct({
49
55
  exclude: Schema.optional(Schema.Array(Schema.String.check(Schema.isPattern(SKILL_SELECTOR_PATTERN)))),
50
56
  setup: Schema.optional(
51
57
  Schema.Struct({
58
+ agentInstructions: Schema.optional(AgentInstructionsSetupSchema),
52
59
  claudeInstructions: Schema.optional(ClaudeInstructionsSetupSchema),
53
60
  effectSource: Schema.optional(EffectSourceSetupSchema),
54
61
  effectTsgo: Schema.optional(EffectTsgoSetupSchema),
@@ -75,6 +82,9 @@ export type NormalizedManifest = {
75
82
  readonly include: ReadonlyArray<string>;
76
83
  readonly exclude: ReadonlyArray<string>;
77
84
  readonly setup: {
85
+ readonly agentInstructions: {
86
+ readonly enabled: boolean;
87
+ };
78
88
  readonly claudeInstructions: {
79
89
  readonly enabled: boolean;
80
90
  };
@@ -125,6 +135,9 @@ export const normalizeManifest = (manifest: DevKitManifest): NormalizedManifest
125
135
  exclude: manifest.exclude ?? [],
126
136
  include: manifest.include,
127
137
  setup: {
138
+ agentInstructions: {
139
+ enabled: manifest.setup?.agentInstructions?.enabled ?? false,
140
+ },
128
141
  claudeInstructions: {
129
142
  enabled: manifest.setup?.claudeInstructions?.enabled ?? false,
130
143
  },
@@ -1,6 +1,7 @@
1
1
  import { Effect, FileSystem, Path, Result, Schema } from "effect";
2
2
 
3
3
  import { observeSymbolicLink } from "./node-symbolic-link.ts";
4
+ import { readDirectDependencyNames } from "./project-package.ts";
4
5
  import { isSkillName, parseSkillSelector } from "./skill-selector.ts";
5
6
  import { isTypeScriptPackageName } from "./typescript-package-name.ts";
6
7
 
@@ -24,13 +25,6 @@ export type DiscoveredPackageSkill = {
24
25
  readonly linkPath: string;
25
26
  };
26
27
 
27
- const ProjectPackageSchema = Schema.fromJsonString(Schema.Struct({
28
- dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
29
- devDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
30
- optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
31
- peerDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
32
- }));
33
-
34
28
  const PackageMetadataSchema = Schema.fromJsonString(Schema.Struct({
35
29
  name: Schema.String,
36
30
  version: Schema.String,
@@ -114,22 +108,6 @@ const rejectNestedSymlinks = Effect.fn("rejectPackageSkillSymlinks")(function* (
114
108
  }
115
109
  });
116
110
 
117
- const readDirectDependencyNames = Effect.fn("readDirectPackageSkillDependencyNames")(function* (projectDir: string) {
118
- const fs = yield* FileSystem.FileSystem;
119
- const path = yield* Path.Path;
120
- const manifestPath = path.join(projectDir, "package.json");
121
- const manifest = yield* fs.readFileString(manifestPath).pipe(
122
- Effect.flatMap(Schema.decodeUnknownEffect(ProjectPackageSchema)),
123
- Effect.mapError(() => new PackageSkillSourceError({ message: `invalid project package.json: ${manifestPath}` })),
124
- );
125
- return [...new Set([
126
- ...Object.keys(manifest.dependencies ?? {}),
127
- ...Object.keys(manifest.devDependencies ?? {}),
128
- ...Object.keys(manifest.optionalDependencies ?? {}),
129
- ...Object.keys(manifest.peerDependencies ?? {}),
130
- ])].sort();
131
- });
132
-
133
111
  type InstalledPackageSkills = {
134
112
  readonly package: string;
135
113
  readonly version: string;
@@ -135,6 +135,13 @@ export const digestText = Effect.fn("digestText")(function* (value: string) {
135
135
  return yield* digestFrames(["text-v1", value]);
136
136
  });
137
137
 
138
+ export const digestFileContent = Effect.fn("digestFileContent")(function* (
139
+ value: string,
140
+ mode = 0o644,
141
+ ) {
142
+ return yield* digestFrames(["file-v1", String(mode), value]);
143
+ });
144
+
138
145
  export const digestSymlinkTarget = Effect.fn("digestSymlinkTarget")(function* (target: string) {
139
146
  return yield* digestFrames(["symlink-v1", target]);
140
147
  });
@@ -0,0 +1,34 @@
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+
3
+ export class ProjectPackageError extends Schema.TaggedErrorClass<ProjectPackageError>()(
4
+ "ProjectPackageError",
5
+ { message: Schema.String },
6
+ ) {}
7
+
8
+ const ProjectPackageSchema = Schema.fromJsonString(Schema.Struct({
9
+ dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
10
+ devDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
11
+ optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
12
+ peerDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
13
+ }));
14
+
15
+ export const readDirectDependencyNames = Effect.fn("readDirectDependencyNames")(function* (
16
+ projectDir: string,
17
+ ) {
18
+ const fs = yield* FileSystem.FileSystem;
19
+ const path = yield* Path.Path;
20
+ const manifestPath = path.join(projectDir, "package.json");
21
+ if (!(yield* fs.exists(manifestPath))) return [];
22
+ const manifest = yield* fs.readFileString(manifestPath).pipe(
23
+ Effect.flatMap(Schema.decodeUnknownEffect(ProjectPackageSchema)),
24
+ Effect.mapError(() => new ProjectPackageError({
25
+ message: `invalid project package.json: ${manifestPath}`,
26
+ })),
27
+ );
28
+ return [...new Set([
29
+ ...Object.keys(manifest.dependencies ?? {}),
30
+ ...Object.keys(manifest.devDependencies ?? {}),
31
+ ...Object.keys(manifest.optionalDependencies ?? {}),
32
+ ...Object.keys(manifest.peerDependencies ?? {}),
33
+ ])].sort();
34
+ });
@@ -29,7 +29,17 @@ export const ManagedSkillOutputSchema = Schema.Struct({
29
29
  });
30
30
  export type ManagedSkillOutput = typeof ManagedSkillOutputSchema.Type;
31
31
 
32
- export const ManagedInstructionOutputSchema = Schema.Struct({
32
+ export const ManagedAgentInstructionsOutputSchema = Schema.Struct({
33
+ resourceId: Schema.Literal("setup:agent-instructions"),
34
+ path: Schema.String,
35
+ sourcePath: Schema.String,
36
+ mode: Schema.Literal("copy"),
37
+ kind: Schema.Literal("file"),
38
+ digest: DigestSchema,
39
+ });
40
+ export type ManagedAgentInstructionsOutput = typeof ManagedAgentInstructionsOutputSchema.Type;
41
+
42
+ export const ManagedClaudeInstructionsOutputSchema = Schema.Struct({
33
43
  resourceId: Schema.Literal("setup:claude-instructions"),
34
44
  path: Schema.String,
35
45
  sourcePath: Schema.String,
@@ -37,6 +47,12 @@ export const ManagedInstructionOutputSchema = Schema.Struct({
37
47
  kind: Schema.Literal("symlink"),
38
48
  digest: DigestSchema,
39
49
  });
50
+ export type ManagedClaudeInstructionsOutput = typeof ManagedClaudeInstructionsOutputSchema.Type;
51
+
52
+ export const ManagedInstructionOutputSchema = Schema.Union([
53
+ ManagedAgentInstructionsOutputSchema,
54
+ ManagedClaudeInstructionsOutputSchema,
55
+ ]);
40
56
  export type ManagedInstructionOutput = typeof ManagedInstructionOutputSchema.Type;
41
57
 
42
58
  export const ManagedOutputSchema = Schema.Union([
@@ -79,7 +95,7 @@ export const OwnershipReceiptSchema = Schema.Struct({
79
95
  resourceId: Schema.String,
80
96
  path: Schema.String,
81
97
  mode: Schema.Literals(["copy", "symlink"]),
82
- kind: Schema.Literals(["directory", "symlink"]),
98
+ kind: Schema.Literals(["file", "directory", "symlink"]),
83
99
  digest: DigestSchema,
84
100
  });
85
101
  export type OwnershipReceipt = typeof OwnershipReceiptSchema.Type;
package/src/sync.ts CHANGED
@@ -21,19 +21,22 @@ import {
21
21
  type EffectTsgoPatchPlan,
22
22
  } from "./effect-tsgo.ts";
23
23
  import {
24
+ digestFileContent,
24
25
  digestSymlinkTarget,
25
26
  digestText,
26
27
  observePath,
27
28
  type ObservedPath,
28
29
  } from "./path-digest.ts";
29
30
  import { resolvePackageSkillSelector } from "./package-skill-source.ts";
31
+ import { readDirectDependencyNames } from "./project-package.ts";
30
32
  import { parseSkillSelector } from "./skill-selector.ts";
31
33
  import {
32
34
  AppliedStateSchema,
33
35
  DevKitLockSchema,
34
36
  type AppliedState,
35
37
  type DevKitLock,
36
- type ManagedInstructionOutput,
38
+ type ManagedAgentInstructionsOutput,
39
+ type ManagedClaudeInstructionsOutput,
37
40
  type ManagedOutput,
38
41
  type ManagedSkillOutput,
39
42
  type OwnershipReceipt,
@@ -76,12 +79,20 @@ type DesiredSkillOutput =
76
79
  readonly linkTarget: string;
77
80
  });
78
81
 
79
- type DesiredInstructionOutput = ManagedInstructionOutput & {
82
+ type DesiredAgentInstructionsOutput = ManagedAgentInstructionsOutput & {
83
+ readonly content: string;
84
+ readonly destination: string;
85
+ };
86
+
87
+ type DesiredClaudeInstructionsOutput = ManagedClaudeInstructionsOutput & {
80
88
  readonly destination: string;
81
89
  readonly linkTarget: string;
82
90
  };
83
91
 
84
- type DesiredOutput = DesiredSkillOutput | DesiredInstructionOutput;
92
+ type DesiredOutput =
93
+ | DesiredSkillOutput
94
+ | DesiredAgentInstructionsOutput
95
+ | DesiredClaudeInstructionsOutput;
85
96
 
86
97
  type SkillPlanAction =
87
98
  | {
@@ -196,10 +207,14 @@ class ApplyRaceError extends Schema.TaggedErrorClass<ApplyRaceError>()("ApplyRac
196
207
  }
197
208
  }
198
209
 
199
- const SKILL_FAMILIES: SkillCatalog = { effect: ["effect-ts"] };
210
+ const SKILL_FAMILIES: SkillCatalog = {
211
+ effect: ["effect-ts", "effect-atom-data-fetching"],
212
+ };
200
213
  export const DEFAULT_MANIFEST = "dev-kit.jsonc";
201
214
  const DEFAULT_LOCKFILE = "dev-kit.lock.json";
202
215
  const DEFAULT_STATE = ".dev-kit/state.json";
216
+ const AGENT_INSTRUCTIONS_TEMPLATE = "templates/AGENTS.md";
217
+ const DEV_KIT_SKILL_PATH_PLACEHOLDER = "{{DEV_KIT_SKILL_PATH}}";
203
218
 
204
219
  const resolvePackageRoot = Effect.fn("resolvePackageRoot")(function* () {
205
220
  const path = yield* Path.Path;
@@ -421,7 +436,51 @@ const validateCrossInventoryPaths = Effect.fn("validateCrossInventoryPaths")(fun
421
436
  }
422
437
  });
423
438
 
439
+ const renderAgentInstructions = Effect.fn("renderAgentInstructions")(function* (
440
+ packageRoot: string,
441
+ projectDir: string,
442
+ sourceBySkill: ReadonlyMap<string, ResolvedSkillSource>,
443
+ ) {
444
+ const fs = yield* FileSystem.FileSystem;
445
+ const path = yield* Path.Path;
446
+ const templatePath = path.join(packageRoot, AGENT_INSTRUCTIONS_TEMPLATE);
447
+ if ((yield* observePath(templatePath)).kind !== "file") {
448
+ return yield* new InvalidProjectStateError({
449
+ message: `dev-kit agent instructions template is not a regular file: ${AGENT_INSTRUCTIONS_TEMPLATE}`,
450
+ });
451
+ }
452
+ const template = yield* fs.readFileString(templatePath);
453
+ if (!template.includes(DEV_KIT_SKILL_PATH_PLACEHOLDER)) {
454
+ return yield* new InvalidProjectStateError({
455
+ message: `dev-kit agent instructions template is missing ${DEV_KIT_SKILL_PATH_PLACEHOLDER}`,
456
+ });
457
+ }
458
+
459
+ const devKitSkill = sourceBySkill.get("dev-kit");
460
+ const devKitSkillPath = devKitSkill === undefined
461
+ ? "node_modules/@danieljvdm/dev-kit/skills/dev-kit/SKILL.md"
462
+ : portablePath(
463
+ path,
464
+ path.relative(
465
+ projectDir,
466
+ path.join(devKitSkill.linkPath ?? devKitSkill.path, "SKILL.md"),
467
+ ),
468
+ );
469
+ const sections = [template.replaceAll(DEV_KIT_SKILL_PATH_PLACEHOLDER, devKitSkillPath).trimEnd()];
470
+ if ((yield* readDirectDependencyNames(projectDir)).includes("vite-plus")) {
471
+ const vitePlusTemplate = path.join(projectDir, "node_modules", "vite-plus", "AGENTS.md");
472
+ if ((yield* observePath(vitePlusTemplate)).kind !== "file") {
473
+ return yield* new InvalidProjectStateError({
474
+ message: "Vite+ is a direct dependency but its agent instructions are not a regular file: node_modules/vite-plus/AGENTS.md",
475
+ });
476
+ }
477
+ sections.push((yield* fs.readFileString(vitePlusTemplate)).trim());
478
+ }
479
+ return `${sections.join("\n\n")}\n`;
480
+ });
481
+
424
482
  const buildDesiredOutputs = Effect.fn("buildDesiredSkillOutputs")(function* (
483
+ packageRoot: string,
425
484
  projectDir: string,
426
485
  sourceBySkill: ReadonlyMap<string, ResolvedSkillSource>,
427
486
  skills: ReadonlyArray<CatalogSkill>,
@@ -430,10 +489,26 @@ const buildDesiredOutputs = Effect.fn("buildDesiredSkillOutputs")(function* (
430
489
  ) {
431
490
  const path = yield* Path.Path;
432
491
  const outputs: Array<DesiredOutput> = [];
492
+ if (setup.agentInstructions.enabled) {
493
+ const managed = yield* resolveManagedPath(projectDir, "AGENTS.md");
494
+ const content = yield* renderAgentInstructions(packageRoot, projectDir, sourceBySkill);
495
+ outputs.push({
496
+ resourceId: "setup:agent-instructions",
497
+ path: managed.relative,
498
+ sourcePath: AGENT_INSTRUCTIONS_TEMPLATE,
499
+ mode: "copy",
500
+ kind: "file",
501
+ digest: yield* digestFileContent(content),
502
+ destination: managed.absolute,
503
+ content,
504
+ });
505
+ }
433
506
  if (setup.claudeInstructions.enabled) {
434
507
  const source = yield* resolveManagedPath(projectDir, "AGENTS.md");
435
- const sourceObservation = yield* observePath(source.absolute);
436
- if (sourceObservation.kind !== "file") {
508
+ const sourceObservation = setup.agentInstructions.enabled
509
+ ? undefined
510
+ : yield* observePath(source.absolute);
511
+ if (!setup.agentInstructions.enabled && sourceObservation?.kind !== "file") {
437
512
  return yield* new InvalidProjectStateError({
438
513
  message: "Claude instructions source is not a regular file: AGENTS.md",
439
514
  });
@@ -673,6 +748,7 @@ export const planProjectSkills = Effect.fn("planProjectSkills")(function* (optio
673
748
  selectedSkills.push(catalogSkill);
674
749
  }
675
750
  const desired = yield* buildDesiredOutputs(
751
+ packageRoot,
676
752
  projectDir,
677
753
  sourceBySkill,
678
754
  selectedSkills,
@@ -705,27 +781,38 @@ export const planProjectSkills = Effect.fn("planProjectSkills")(function* (optio
705
781
  },
706
782
  }),
707
783
  },
708
- outputs: desired.map((output): ManagedOutput =>
709
- "skill" in output
710
- ? {
711
- resourceId: output.resourceId,
712
- path: output.path,
713
- skill: output.skill,
714
- target: output.target,
715
- mode: output.mode,
716
- kind: output.kind,
717
- digest: output.digest,
718
- ...(output.catalog ? { catalog: output.catalog } : {}),
719
- }
720
- : {
721
- resourceId: output.resourceId,
722
- path: output.path,
723
- sourcePath: output.sourcePath,
724
- mode: output.mode,
725
- kind: output.kind,
726
- digest: output.digest,
727
- },
728
- ),
784
+ outputs: desired.map((output): ManagedOutput => {
785
+ if ("skill" in output) {
786
+ return {
787
+ resourceId: output.resourceId,
788
+ path: output.path,
789
+ skill: output.skill,
790
+ target: output.target,
791
+ mode: output.mode,
792
+ kind: output.kind,
793
+ digest: output.digest,
794
+ ...(output.catalog ? { catalog: output.catalog } : {}),
795
+ };
796
+ }
797
+ if (output.resourceId === "setup:agent-instructions") {
798
+ return {
799
+ resourceId: output.resourceId,
800
+ path: output.path,
801
+ sourcePath: output.sourcePath,
802
+ mode: output.mode,
803
+ kind: output.kind,
804
+ digest: output.digest,
805
+ };
806
+ }
807
+ return {
808
+ resourceId: output.resourceId,
809
+ path: output.path,
810
+ sourcePath: output.sourcePath,
811
+ mode: output.mode,
812
+ kind: output.kind,
813
+ digest: output.digest,
814
+ };
815
+ }),
729
816
  };
730
817
  const reservedPaths = [
731
818
  { label: "manifest", path: manifestManaged.relative },
@@ -739,6 +826,17 @@ export const planProjectSkills = Effect.fn("planProjectSkills")(function* (optio
739
826
  yield* validateReservedPaths(projectDir, reservedPaths, desired);
740
827
  const currentLock = yield* readOptionalStructuredFile(lockManaged.absolute, DevKitLockSchema);
741
828
  const currentState = yield* readOptionalStructuredFile(stateManaged.absolute, AppliedStateSchema);
829
+ if (
830
+ manifest.setup.claudeInstructions.enabled &&
831
+ !manifest.setup.agentInstructions.enabled &&
832
+ currentState?.outputs.some(
833
+ (output) => output.resourceId === "setup:agent-instructions",
834
+ )
835
+ ) {
836
+ return yield* new InvalidProjectStateError({
837
+ message: "cannot disable agentInstructions while claudeInstructions still links to its AGENTS.md wrapper",
838
+ });
839
+ }
742
840
  yield* validateReservedPaths(
743
841
  projectDir,
744
842
  reservedPaths,
@@ -898,12 +996,16 @@ const applyPlannedSkillChanges = Effect.fn("applyPlannedSkillChanges")(function*
898
996
  const staged = path.join(stageDir, String(stageIndex++));
899
997
  yield* fs.makeDirectory(path.dirname(staged), { recursive: true });
900
998
  if (action.desired.mode === "copy") {
901
- yield* fs.copy(action.desired.source, staged, { overwrite: true });
902
- const symbolicLink = yield* findNestedSymbolicLink(staged);
903
- if (symbolicLink !== undefined) {
904
- return yield* new InvalidProjectStateError({
905
- message: `staged skill contains a symlink: ${action.desired.path}`,
906
- });
999
+ if (action.desired.kind === "file") {
1000
+ yield* fs.writeFileString(staged, action.desired.content, { mode: 0o644 });
1001
+ } else {
1002
+ yield* fs.copy(action.desired.source, staged, { overwrite: true });
1003
+ const symbolicLink = yield* findNestedSymbolicLink(staged);
1004
+ if (symbolicLink !== undefined) {
1005
+ return yield* new InvalidProjectStateError({
1006
+ message: `staged skill contains a symlink: ${action.desired.path}`,
1007
+ });
1008
+ }
907
1009
  }
908
1010
  } else {
909
1011
  yield* fs.symlink(action.desired.linkTarget, staged);
@@ -0,0 +1,9 @@
1
+ <!-- DEV KIT START -->
2
+
3
+ # Dev Kit
4
+
5
+ This project uses `@danieljvdm/dev-kit` to manage portable agent skills and reproducible setup from `dev-kit.jsonc` and `dev-kit.lock.json`.
6
+
7
+ For dev-kit operations, use the `dev-kit` skill and read `{{DEV_KIT_SKILL_PATH}}` before changing managed outputs.
8
+
9
+ <!-- DEV KIT END -->