@danieljvdm/dev-kit 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +155 -99
  2. package/dev-kit.example.jsonc +4 -3
  3. package/package.json +1 -1
  4. package/schema/dev-kit.schema.json +19 -42
  5. package/skills/build-effect-apis/SKILL.md +13 -31
  6. package/skills/build-effect-apis/references/verification.md +3 -3
  7. package/skills/dev-kit/SKILL.md +115 -222
  8. package/skills/effect-atom-state/SKILL.md +97 -0
  9. package/skills/effect-atom-state/agents/openai.yaml +4 -0
  10. package/skills/effect-atom-state/references/effect-atom-workflows.md +180 -0
  11. package/skills/open-pull-request/SKILL.md +62 -23
  12. package/src/bin/dev-kit.ts +21 -0
  13. package/src/catalog.ts +39 -15
  14. package/src/effect-source.ts +70 -4
  15. package/src/global-cache.ts +304 -0
  16. package/src/index.ts +6 -6
  17. package/src/manifest.ts +28 -29
  18. package/src/oxlint.js +23 -0
  19. package/src/oxlint.ts +37 -1
  20. package/src/path-digest.ts +0 -13
  21. package/src/project-package.ts +127 -12
  22. package/src/project-state.ts +3 -0
  23. package/src/scaffold.ts +79 -0
  24. package/src/sync.ts +100 -173
  25. package/src/vite-plus-workflow.ts +82 -0
  26. package/src/vite-plus.js +8 -1
  27. package/src/vite-plus.ts +15 -1
  28. package/src/worktrunk-config.ts +88 -0
  29. package/templates/vite-plus/github-actions-check.yml +0 -2
  30. package/templates/worktrunk/wt.toml +27 -0
  31. package/src/vite-plus-quality.ts +0 -148
  32. /package/skills/{build-effect-apis → effect-atom-state}/references/effect-atom-client.md +0 -0
  33. /package/skills/{build-effect-apis → effect-atom-state}/references/effect-atom-lifecycle.md +0 -0
  34. /package/skills/{build-effect-apis → effect-atom-state}/references/effect-atom-testing.md +0 -0
  35. /package/skills/{build-effect-apis → effect-atom-state}/references/tanstack-start.md +0 -0
@@ -0,0 +1,180 @@
1
+ # Effect Atom workflows
2
+
3
+ Multi-step client actions are Effects composing atoms. Components dispatch
4
+ them and render `AsyncResult` state; no orchestration crosses the React
5
+ boundary. Confirm exact signatures against the installed
6
+ `effect/unstable/reactivity` declarations before copying.
7
+
8
+ - [Define workflow atoms with Atom.fn](#define-workflow-atoms-with-atomfn)
9
+ - [Compose atoms through the fn context](#compose-atoms-through-the-fn-context)
10
+ - [Overlay optimistic query values](#overlay-optimistic-query-values)
11
+ - [Dispatch from React](#dispatch-from-react)
12
+ - [Reinforce the boundary with a lint rule](#reinforce-the-boundary-with-a-lint-rule)
13
+
14
+ ## Define workflow atoms with Atom.fn
15
+
16
+ `Atom.fn<Input>()(effect)` creates a writable atom: writing an input runs the
17
+ effect, and the atom's value is the `AsyncResult` of the latest run. The
18
+ effect receives `(input, get: Atom.FnContext)`.
19
+
20
+ Bare `Atom.fn` accepts no reactivity keys. When the workflow needs Effect
21
+ services or invalidation, create it through a runtime factory —
22
+ `AtomHttpApi.Service` exposes its own as `Client.runtime.fn`:
23
+
24
+ ```ts
25
+ import { Effect } from "effect";
26
+ import { Atom, Reactivity } from "effect/unstable/reactivity";
27
+
28
+ export const updateProject = ApiClient.runtime.fn(
29
+ Effect.fnUntraced(function* (input: { readonly projectId: ProjectId; readonly patch: Patch }) {
30
+ const client = yield* ApiClient;
31
+
32
+ return yield* Reactivity.mutation(
33
+ client.projects.updateProject({
34
+ params: { projectId: input.projectId },
35
+ payload: input.patch,
36
+ }),
37
+ [...projectKeys.collection, ...projectKeys.project(input.projectId)],
38
+ );
39
+ }),
40
+ );
41
+ ```
42
+
43
+ Prefer `Client.mutation(group, endpoint)` with call-site `reactivityKeys` for a
44
+ single request; wrap the client call in `runtime.fn` with
45
+ `Reactivity.mutation(effect, keys)` when the key set depends on the input or
46
+ the workflow spans several requests. Either way, keys invalidate only when the
47
+ effect succeeds.
48
+
49
+ Invalidation spans clients: every `AtomHttpApi.Service` built on the same
50
+ runtime factory shares one `Reactivity` instance, so a mutation on one client
51
+ invalidates query keys registered by another. A service given its own
52
+ `Atom.context()` gets a separate `Reactivity` and cannot invalidate the rest —
53
+ share one runtime factory across API services on purpose.
54
+
55
+ ## Compose atoms through the fn context
56
+
57
+ Inside the effect, the fn context is the composition surface:
58
+
59
+ - `get.setResult(fnAtom, input)` writes another fn atom and returns an Effect
60
+ of its settled result — the await-another-workflow primitive;
61
+ - `get.set(stateAtom, value)` writes a state atom;
62
+ - `get(atom)` reads the current value **untracked** — the workflow never
63
+ subscribes, so mutating a state atom from inside the effect cannot
64
+ re-trigger it.
65
+
66
+ Optimistic echo with rollback, keyed by the owning entity so the atoms
67
+ dispose with it:
68
+
69
+ ```ts
70
+ import { Effect, Exit } from "effect";
71
+
72
+ interface PendingComment {
73
+ readonly id: string;
74
+ readonly body: string;
75
+ readonly status: "sending" | "queued";
76
+ }
77
+
78
+ export const pendingCommentsAtom = Atom.family((_projectId: ProjectId) =>
79
+ Atom.make<ReadonlyArray<PendingComment>>([]),
80
+ );
81
+
82
+ export const sendCommentWithEcho = Atom.family((projectId: ProjectId) =>
83
+ Atom.fn<{ readonly body: string }>()(
84
+ Effect.fnUntraced(function* (input, get) {
85
+ const pending = pendingCommentsAtom(projectId);
86
+ const id = yield* Effect.sync(() => crypto.randomUUID());
87
+
88
+ get.set(pending, [...get(pending), { id, body: input.body, status: "sending" }]);
89
+
90
+ const exit = yield* Effect.exit(get.setResult(sendComment, { projectId, body: input.body }));
91
+
92
+ get.set(
93
+ pending,
94
+ Exit.isSuccess(exit)
95
+ ? get(pending).map((entry) =>
96
+ entry.id === id ? { ...entry, status: "queued" as const } : entry,
97
+ )
98
+ : get(pending).filter((entry) => entry.id !== id),
99
+ );
100
+
101
+ yield* exit;
102
+ }),
103
+ ),
104
+ );
105
+ ```
106
+
107
+ The pending list lives in an `Atom.family` state atom, not component
108
+ `useState`, so the workflow owns append, settle, and rollback while any
109
+ component can render it. Re-raise the exit so the dispatching leaf still
110
+ observes failure.
111
+
112
+ ## Overlay optimistic query values
113
+
114
+ When the optimistic value is the query's own value rather than a sidecar list,
115
+ wrap the query with `Atom.optimistic` and drive it with `Atom.optimisticFn`:
116
+
117
+ ```ts
118
+ import { AsyncResult } from "effect/unstable/reactivity";
119
+
120
+ export const timelineAtom = Atom.family((projectId: ProjectId) =>
121
+ Atom.optimistic(timelineQuery(projectId)),
122
+ );
123
+
124
+ export const sendMessage = Atom.family((projectId: ProjectId) =>
125
+ Atom.optimisticFn(timelineAtom(projectId), {
126
+ reducer: (current, message: MessageDto) =>
127
+ AsyncResult.map(current, (page) => ({
128
+ ...page,
129
+ messages: [...page.messages, message],
130
+ })),
131
+ fn: submitMessage(projectId),
132
+ }),
133
+ );
134
+ ```
135
+
136
+ The reducer computes the provisional value shown while the mutation runs; a
137
+ successful transition refreshes the source query, and a failure rolls the
138
+ value back to the latest source value. Consumers render `timelineAtom` and
139
+ never see the seam.
140
+
141
+ ## Dispatch from React
142
+
143
+ A promise-mode dispatch is handed bare to a leaf component whose contract is
144
+ promise-shaped; presentation derives from the atom's `AsyncResult`:
145
+
146
+ ```tsx
147
+ const [sendResult, send] = useAtom(sendMessage(projectId), { mode: "promise" });
148
+ const hint = AsyncResult.isSuccess(sendResult) && !sendResult.waiting ? "sent" : undefined;
149
+
150
+ return <Composer hint={hint} onSubmit={(body) => send({ body })} />;
151
+ ```
152
+
153
+ The returned promise resolves with the success value and rejects with the
154
+ squashed failure cause; use `mode: "promiseExit"` when the leaf needs the full
155
+ `Exit`. Anything more than returning the promise — chaining a refresh, echo
156
+ bookkeeping, sequencing a second mutation — belongs in the workflow atom.
157
+
158
+ ## Reinforce the boundary with a lint rule
159
+
160
+ A repository can back the logic-free boundary with a lint warning on `then`
161
+ scoped to component and route modules, mirroring the typed-codec lint pattern:
162
+
163
+ ```ts
164
+ {
165
+ files: ["apps/web/src/components/**", "apps/web/src/routes/**"],
166
+ rules: {
167
+ "no-restricted-properties": [
168
+ "warn",
169
+ {
170
+ property: "then",
171
+ message:
172
+ "Compose the workflow in Effect (Atom.fn + reactivity keys) and return promise-mode dispatches bare to the leaf component.",
173
+ },
174
+ ],
175
+ },
176
+ }
177
+ ```
178
+
179
+ Keep any justified suppression local and documented; the boundary reasoning,
180
+ not the lint rule, remains the source of truth.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: open-pull-request
3
- description: Open pull requests with conventional commits, terse context-complete English descriptions, and verified proof of work. Use whenever preparing or opening a pull request, including checking commit history, drafting the title or body, and attaching screenshots or other evidence.
3
+ description: Open pull requests with conventional commits, reviewer-complete descriptions, links to vital code, and concrete evidence. Use whenever preparing or opening a pull request, including checking commit history, explaining a bug or architectural change, drafting the title or body, and attaching screenshots or other evidence.
4
4
  ---
5
5
 
6
6
  # Open a Pull Request
@@ -22,18 +22,38 @@ task conversation.
22
22
  `build`, `ci`, or `chore`. Keep each commit to one logical concern. Rewrite
23
23
  only commits you created and know are unshared; get approval before
24
24
  rewriting user-authored or published history.
25
- 3. Run the repository's required validation on the final branch state. Record
26
- the exact commands and results, then collect the strongest available proof
27
- of the changed behavior. Finish when every claim in the PR can be traced to
28
- the diff, a check result, or an artifact.
25
+ 3. Run the repository's required validation on the final branch state, then
26
+ collect the strongest available evidence of the changed behavior. Identify
27
+ the few files, symbols, or modules a reviewer must understand and prepare
28
+ links that resolve in the rendered PR. Finish when every claim can be traced
29
+ to the diff, CI, or a verified artifact.
29
30
 
30
31
  ## Write for the reviewer
31
32
 
32
- Write terse, plain English for someone with little context. Lead with the
33
- observable outcome and add only the minimum reason needed to understand it.
34
- Prefer short bullets and concrete nouns. Expand uncommon acronyms. Describe
35
- behavior and impact rather than narrating files, implementation steps, or the
36
- task conversation.
33
+ Write clear, compact English for someone with little context. Give the reviewer
34
+ enough explanation to agree with both the problem and the solution; do not
35
+ sacrifice causal or architectural context for brevity. Lead with the observable
36
+ outcome, then explain why the change was needed and how the important pieces
37
+ fit together. Prefer concrete nouns and expand uncommon acronyms. Describe
38
+ behavior and impact rather than narrating the task conversation.
39
+
40
+ Link the vital implementation points from the summary or architecture section.
41
+ Use descriptive link text that names each piece by its role, such as the request
42
+ router or cache invalidation boundary, and verify every link after opening the
43
+ PR. Link the core pieces a reviewer should inspect, not every touched file.
44
+
45
+ For every bug fix, include a **What went wrong** section in plain English. State
46
+ the incorrect behavior, its actual root cause and causal chain, and why the
47
+ change fixes it. Make uncertainty or incomplete coverage explicit. A result
48
+ such as "fixed stale state" is not a diagnosis; explain how the stale state was
49
+ created or allowed to survive.
50
+
51
+ When the change alters architecture, identify the affected components and
52
+ boundaries, what each one owns after the change, and any important change to
53
+ control flow, data flow, public contracts, or persistence. Link to the core
54
+ implementation of each affected piece. Use a dedicated **Architecture** section
55
+ when this would make the change easier to review; otherwise include the context
56
+ in the summary.
37
57
 
38
58
  Use the repository's required template when present. Otherwise use this small
39
59
  shape and omit empty sections:
@@ -42,38 +62,57 @@ shape and omit empty sections:
42
62
  ## Summary
43
63
 
44
64
  - <What changes for a user, operator, or developer>
45
- - <Why it matters, only when the first bullet does not make that clear>
65
+ - <Why it matters and the shape of the solution, with links to vital code>
66
+
67
+ ## What went wrong
68
+
69
+ <For a bug fix: explain the symptom, root cause, causal chain, and why this fix
70
+ addresses it.>
46
71
 
47
- ## Proof
72
+ ## Architecture
48
73
 
49
- - `<validation command>` passed
50
- - <Screenshot, sample output, or other verified artifact>
74
+ - <When applicable: explain the changed components, ownership, and flow, with
75
+ links to their core implementations.>
76
+
77
+ ## Evidence
78
+
79
+ - <Screenshot, before/after output, request/response, trace, or other verified
80
+ artifact>
51
81
  ```
52
82
 
53
- Keep the summary to one to three bullets. Make the title specific enough to
83
+ Keep the body proportional to the change: a small change may need two useful
84
+ bullets, while a subtle bug or architectural change may need several paragraphs.
85
+ Omit conditional sections that do not apply. Make the title specific enough to
54
86
  stand alone in release notes and conventional enough to become the squash
55
87
  commit without editing.
56
88
 
57
- ## Show proof of work
89
+ ## Show useful evidence
58
90
 
59
- Proof is something the reviewer can inspect, not an assertion that the change
60
- works.
91
+ Evidence is something the reviewer can inspect, not an assertion that the
92
+ change works.
61
93
 
62
94
  - For a runnable UI or visual feature, capture and attach a screenshot or short
63
95
  recording of the actual final state. Use a representative viewport, add a
64
96
  short caption, and check the artifact for secrets or personal data.
65
97
  - For CLI, API, or automation behavior, include concise terminal output, a
66
98
  request/response example, generated artifact, or execution log when it proves
67
- more than the validation command alone.
99
+ the behavior more clearly than the CI result alone.
68
100
  - For a bug fix or behavior change, prefer before/after evidence when it is
69
101
  practical and materially clarifies the result.
70
- - For internal-only changes, exact passing validation commands may be the most
71
- useful proof.
102
+ - For internal-only changes, include focused regression output, a trace, a
103
+ generated artifact, or another result that demonstrates the changed behavior
104
+ when available.
105
+
106
+ Routine validation commands that CI always runs, such as `vp check` or standard
107
+ format, lint, typecheck, and test commands, add no useful context to the PR body.
108
+ Let CI report them. Mention a command or CI result only when it is unusual,
109
+ cannot run in CI, or its output itself helps the reviewer understand the change.
72
110
 
73
111
  Include only evidence that was actually produced and verified. When expected
74
112
  visual proof cannot be produced, state the concrete reason briefly instead of
75
- silently substituting a claim. Preserve terse descriptions by choosing the
76
- smallest set of evidence that proves the outcome.
113
+ silently substituting a claim. Choose the smallest set of evidence that makes
114
+ the changed behavior easy to inspect. Omit the section when no evidence adds
115
+ information beyond routine CI.
77
116
 
78
117
  ## Open and verify
79
118
 
@@ -12,6 +12,7 @@ import { printError } from "../cli-ui.ts";
12
12
  import { syncEffectSource } from "../effect-source.ts";
13
13
  import { patchEffectTsgo } from "../effect-tsgo.ts";
14
14
  import { patchProjectGitignore } from "../gitignore.ts";
15
+ import { CACHE_PRUNE_AGE_DAYS, runCachePrune } from "../global-cache.ts";
15
16
  import {
16
17
  addSkills,
17
18
  chooseSkillsToAdd,
@@ -351,6 +352,25 @@ const catalogVerifyCommand = CliCommand.make(
351
352
  refreshSkillCatalog({ locked: true, lockfilePath: lockfile, repoDir, sourcesPath: sources }),
352
353
  ).pipe(CliCommand.withDescription("Verify the committed catalog without advancing refs."));
353
354
 
355
+ const cachePruneCommand = CliCommand.make(
356
+ "prune",
357
+ {
358
+ all: Flag.boolean("all").pipe(
359
+ Flag.withDescription("Remove the entire cache instead of only stale content."),
360
+ ),
361
+ maxAgeDays: Flag.integer("max-age-days").pipe(
362
+ Flag.withDefault(CACHE_PRUNE_AGE_DAYS),
363
+ Flag.withDescription("Evict content unused for this many days."),
364
+ ),
365
+ },
366
+ ({ all, maxAgeDays }) => runCachePrune({ all, maxAgeDays }),
367
+ ).pipe(CliCommand.withDescription("Evict stale content from the machine-global source cache."));
368
+
369
+ const cacheCommand = CliCommand.make("cache").pipe(
370
+ CliCommand.withDescription("Manage the machine-global source cache."),
371
+ CliCommand.withSubcommands([cachePruneCommand] as const),
372
+ );
373
+
354
374
  const catalogCommand = CliCommand.make("catalog").pipe(
355
375
  CliCommand.withDescription("Maintain the approved upstream catalog."),
356
376
  CliCommand.withSubcommands([
@@ -382,6 +402,7 @@ const command = CliCommand.make("dev-kit", projectFlags, ({ manifest, projectDir
382
402
  effectCommand,
383
403
  tsgoCommand,
384
404
  catalogCommand,
405
+ cacheCommand,
385
406
  ],
386
407
  },
387
408
  ] as const),
package/src/catalog.ts CHANGED
@@ -2,6 +2,11 @@ import { Effect, FileSystem, Path, Schema, Stream } from "effect";
2
2
  import { ChildProcess } from "effect/unstable/process";
3
3
  import { parse as parseJsonc, type ParseError } from "jsonc-parser";
4
4
 
5
+ import {
6
+ commitCacheDirectory,
7
+ resolveGlobalCacheDirectory,
8
+ stampCacheEntryUsage,
9
+ } from "./global-cache.ts";
5
10
  import {
6
11
  discoverPackageSkills,
7
12
  resolvePackageSkillSelector,
@@ -181,7 +186,13 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
181
186
  });
182
187
  }
183
188
  const families: Readonly<Record<string, ReadonlyArray<string>>> = {
184
- effect: ["effect-ts", "effect-architecture-audit", "build-effect-apis", "build-effect-clis"],
189
+ effect: [
190
+ "effect-ts",
191
+ "effect-architecture-audit",
192
+ "build-effect-apis",
193
+ "effect-atom-state",
194
+ "build-effect-clis",
195
+ ],
185
196
  ...Object.fromEntries(externalFamilies),
186
197
  };
187
198
 
@@ -261,26 +272,35 @@ const materializePackageSkill = Effect.fn("materializePackageSkill")(function* (
261
272
  return staged;
262
273
  });
263
274
 
275
+ // Catalog checkouts are keyed by source id and resolved commit SHA, so the
276
+ // materialized content is immutable and shared machine-wide across projects
277
+ // and worktrees. Planning and locked verification populate the same cache:
278
+ // writing an immutable commit-keyed cache entry is not project state.
264
279
  const materializeSource = Effect.fn("materializeCatalogSource")(function* (
265
- projectDir: string,
266
280
  source: LockedSkillSource,
267
281
  selected: ReadonlyArray<string>,
268
- cache: boolean,
269
282
  ) {
270
283
  const fs = yield* FileSystem.FileSystem;
271
284
  const path = yield* Path.Path;
272
- const root = cache
273
- ? path.join(projectDir, ".dev-kit", "cache", "catalog", source.id, source.resolved)
274
- : path.join(
275
- yield* fs.makeTempDirectoryScoped({ prefix: "dev-kit-catalog-plan-" }),
276
- source.id,
277
- source.resolved,
278
- );
279
- const checkout = path.join(root, "checkout");
285
+ const root = path.join(
286
+ yield* resolveGlobalCacheDirectory(),
287
+ "catalog",
288
+ source.id,
289
+ source.resolved,
290
+ );
280
291
  const ready = path.join(root, ".ready");
281
292
 
282
293
  if (!(yield* fs.exists(ready))) {
283
- yield* fs.remove(root, { force: true, recursive: true });
294
+ yield* fs.makeDirectory(path.dirname(root), { recursive: true });
295
+ const staged = path.join(
296
+ yield* fs.makeTempDirectoryScoped({
297
+ directory: path.dirname(root),
298
+ prefix: ".dev-kit-catalog-stage-",
299
+ }),
300
+ source.resolved,
301
+ );
302
+ const checkout = path.join(staged, "checkout");
303
+
284
304
  yield* fs.makeDirectory(checkout, { recursive: true });
285
305
  yield* runGit(checkout, ["init", "--quiet"]);
286
306
  yield* runGit(checkout, ["remote", "add", "origin", source.repository]);
@@ -302,7 +322,7 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
302
322
  }
303
323
  for (const skill of source.skills) {
304
324
  const from = path.join(checkout, source.skillsPath, skill);
305
- const to = path.join(root, "skills", skill);
325
+ const to = path.join(staged, "skills", skill);
306
326
  const observation = yield* observePath(from);
307
327
 
308
328
  if (observation.kind !== "directory") {
@@ -320,8 +340,10 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
320
340
  );
321
341
  }
322
342
  }
323
- yield* fs.writeFileString(ready, `${source.resolved}\n`);
343
+ yield* fs.writeFileString(path.join(staged, ".ready"), `${source.resolved}\n`);
344
+ yield* commitCacheDirectory(staged, root, fs.exists(ready));
324
345
  }
346
+ yield* stampCacheEntryUsage(root);
325
347
  for (const skill of selected) {
326
348
  const observation = yield* observePath(path.join(root, "skills", skill));
327
349
  const approvedDigest = source.digests?.[skill];
@@ -351,6 +373,8 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
351
373
  );
352
374
  });
353
375
 
376
+ // The cache flag only affects package skills, whose staging area is project
377
+ // state under .dev-kit; catalog sources always use the machine-global cache.
354
378
  export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
355
379
  packageRoot: string,
356
380
  projectDir: string,
@@ -370,7 +394,7 @@ export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
370
394
  const wanted = source.skills.filter((skill) => selected.includes(skill));
371
395
 
372
396
  if (wanted.length === 0) continue;
373
- for (const [name, sourcePath] of yield* materializeSource(projectDir, source, wanted, cache)) {
397
+ for (const [name, sourcePath] of yield* materializeSource(source, wanted)) {
374
398
  sources.set(name, sourcePath);
375
399
  }
376
400
  }
@@ -2,6 +2,11 @@ import { Config, Effect, FileSystem, Path, Schema, Stream } from "effect";
2
2
  import { ChildProcess } from "effect/unstable/process";
3
3
 
4
4
  import { printStatus, withSpinner } from "./cli-ui.ts";
5
+ import {
6
+ commitCacheDirectory,
7
+ resolveGlobalCacheDirectory,
8
+ stampTagUsage,
9
+ } from "./global-cache.ts";
5
10
  import { observeSymbolicLink } from "./node-symbolic-link.ts";
6
11
  import { acquireProjectProcessLock } from "./project-process-lock.ts";
7
12
  import { isTypeScriptPackageName } from "./typescript-package-name.ts";
@@ -272,12 +277,73 @@ export const planEffectSource = Effect.fn("planEffectSource")(function* (
272
277
  } satisfies EffectSourcePlan;
273
278
  });
274
279
 
280
+ // The shared bare repository accumulates shallow tag fetches machine-wide, so
281
+ // a project checkout only contacts the network when its tag has never been
282
+ // cached on this machine. Entries are keyed by repository URL.
283
+ const ensureSharedRepository = Effect.fn("ensureSharedEffectRepository")(function* (
284
+ repository: string,
285
+ tag: string,
286
+ ) {
287
+ const fs = yield* FileSystem.FileSystem;
288
+ const path = yield* Path.Path;
289
+ const repositoryDir = path.join(
290
+ yield* resolveGlobalCacheDirectory(),
291
+ "effect-source",
292
+ encodeURIComponent(repository),
293
+ );
294
+ const populated = fs.exists(path.join(repositoryDir, "HEAD"));
295
+
296
+ if (!(yield* populated)) {
297
+ yield* fs.makeDirectory(path.dirname(repositoryDir), { recursive: true });
298
+ const staged = path.join(
299
+ yield* fs.makeTempDirectoryScoped({
300
+ directory: path.dirname(repositoryDir),
301
+ prefix: ".dev-kit-effect-source-stage-",
302
+ }),
303
+ "repository",
304
+ );
305
+
306
+ yield* runGit(path.dirname(staged), ["init", "--quiet", "--bare", staged]);
307
+ yield* commitCacheDirectory(staged, repositoryDir, populated);
308
+ }
309
+ yield* stampTagUsage(repositoryDir, tag);
310
+ const cached = yield* runGit(repositoryDir, [
311
+ "rev-parse",
312
+ "-q",
313
+ "--verify",
314
+ `refs/tags/${tag}^{commit}`,
315
+ ]).pipe(Effect.catchTag("EffectSourceCommandError", () => Effect.void));
316
+
317
+ if (cached === undefined) {
318
+ yield* runGit(repositoryDir, [
319
+ "fetch",
320
+ "--depth",
321
+ "1",
322
+ "--force",
323
+ "--quiet",
324
+ repository,
325
+ `refs/tags/${tag}:refs/tags/${tag}`,
326
+ ]).pipe(
327
+ // A concurrent apply may have fetched the tag first; keep its result.
328
+ Effect.catchTag("EffectSourceCommandError", (error) =>
329
+ runGit(repositoryDir, ["rev-parse", "-q", "--verify", `refs/tags/${tag}^{commit}`]).pipe(
330
+ Effect.mapError(() => error),
331
+ Effect.asVoid,
332
+ ),
333
+ ),
334
+ );
335
+ }
336
+
337
+ return repositoryDir;
338
+ });
339
+
275
340
  export const applyEffectSourcePlan = Effect.fn("applyEffectSourcePlan")(function* (
276
341
  plan: EffectSourcePlan,
277
342
  ) {
278
343
  if (plan.action !== "sync") return;
279
344
  const fs = yield* FileSystem.FileSystem;
280
345
  const path = yield* Path.Path;
346
+ const repositoryDir = yield* ensureSharedRepository(plan.repository, plan.tag);
281
347
 
282
348
  if (!(yield* fs.exists(plan.checkoutDir))) {
283
349
  const parent = path.dirname(plan.checkoutDir);
@@ -291,15 +357,15 @@ export const applyEffectSourcePlan = Effect.fn("applyEffectSourcePlan")(function
291
357
 
292
358
  yield* runGit(plan.projectDir, [
293
359
  "clone",
294
- "--depth",
295
- "1",
360
+ "--quiet",
296
361
  "--branch",
297
362
  plan.tag,
298
363
  "--single-branch",
299
364
  "--",
300
- plan.repository,
365
+ repositoryDir,
301
366
  staged,
302
367
  ]);
368
+ yield* runGit(staged, ["remote", "set-url", "origin", plan.repository]);
303
369
  if (yield* fs.exists(plan.checkoutDir)) {
304
370
  return yield* EffectSourceCheckoutError.make({
305
371
  message: `Effect source destination appeared while cloning: ${plan.checkoutDir}`,
@@ -316,7 +382,7 @@ export const applyEffectSourcePlan = Effect.fn("applyEffectSourcePlan")(function
316
382
  "1",
317
383
  "--force",
318
384
  "--quiet",
319
- "origin",
385
+ repositoryDir,
320
386
  `refs/tags/${plan.tag}:refs/tags/${plan.tag}`,
321
387
  ]);
322
388
  const target = yield* runGit(plan.checkoutDir, [