@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 +39 -13
- package/dev-kit.example.jsonc +1 -0
- package/package.json +3 -2
- package/schema/dev-kit.schema.json +14 -0
- package/skills/dev-kit/SKILL.md +24 -9
- package/skills/effect-atom-data-fetching/SKILL.md +40 -0
- package/skills/effect-atom-data-fetching/agents/openai.yaml +4 -0
- package/skills/effect-atom-data-fetching/references/cache-lifecycle.md +72 -0
- package/skills/effect-atom-data-fetching/references/http-and-invalidation.md +93 -0
- package/skills/effect-atom-data-fetching/references/tanstack-start.md +69 -0
- package/skills/effect-atom-data-fetching/references/testing.md +63 -0
- package/src/catalog.ts +1 -1
- package/src/index.ts +6 -0
- package/src/manifest.ts +13 -0
- package/src/package-skill-source.ts +1 -23
- package/src/path-digest.ts +7 -0
- package/src/project-package.ts +34 -0
- package/src/project-state.ts +18 -2
- package/src/sync.ts +135 -33
- package/templates/AGENTS.md +9 -0
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
|
|
77
|
-
|
|
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
|
|
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
|
|
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
|
-
##
|
|
211
|
+
## Agent instructions
|
|
195
212
|
|
|
196
|
-
Enable a
|
|
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
|
-
`
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
package/dev-kit.example.jsonc
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danieljvdm/dev-kit",
|
|
3
|
-
"version": "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",
|
package/skills/dev-kit/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
55
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
disabled, dev-kit removes only
|
|
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
|
|
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.
|
|
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;
|
package/src/path-digest.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/project-state.ts
CHANGED
|
@@ -29,7 +29,17 @@ export const ManagedSkillOutputSchema = Schema.Struct({
|
|
|
29
29
|
});
|
|
30
30
|
export type ManagedSkillOutput = typeof ManagedSkillOutputSchema.Type;
|
|
31
31
|
|
|
32
|
-
export const
|
|
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
|
|
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
|
|
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 =
|
|
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 = {
|
|
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 =
|
|
436
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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 -->
|