@danieljvdm/dev-kit 0.10.0 → 0.11.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
@@ -20,6 +20,9 @@ Install the published Dev Kit package:
20
20
  bun add -d @danieljvdm/dev-kit
21
21
  ```
22
22
 
23
+ Dev Kit requires Bun 1.3 or newer. Its published executable runs TypeScript
24
+ natively with Bun and rejects direct Node.js execution.
25
+
23
26
  Initialize the project, browse available built-in, approved Git, and installed
24
27
  package skills, then add the ones you want:
25
28
 
@@ -94,12 +97,14 @@ before running locked mode:
94
97
 
95
98
  ```bash
96
99
  bun install --ignore-scripts
97
- bun x dev-kit apply --locked
100
+ bun ./node_modules/@danieljvdm/dev-kit/bin/dev-kit.mjs apply --locked
98
101
  ```
99
102
 
100
103
  Or allow the normal postinstall and fail CI when it leaves tracked changes.
101
104
  Do not run an unlocked apply before a locked verification because that would
102
105
  regenerate the drift being checked.
106
+ The package-qualified path cannot be shadowed by a consumer script named
107
+ `dev-kit`.
103
108
 
104
109
  This repository dogfoods the same flow with its committed `dev-kit.jsonc` and
105
110
  `dev-kit.lock.json`. From this source checkout, invoke the local CLI with:
@@ -211,7 +216,7 @@ The patch is idempotent, preserves existing lines, and refuses symlinked
211
216
 
212
217
  ## Agent instructions
213
218
 
214
- Enable a managed project-root instruction wrapper and a portable Claude Code
219
+ Enable managed project-root instruction sections and a portable Claude Code
215
220
  bridge in the manifest:
216
221
 
217
222
  ```jsonc
@@ -224,18 +229,37 @@ bridge in the manifest:
224
229
  }
225
230
  ```
226
231
 
227
- `setup.agentInstructions` manages `AGENTS.md` as a generated wrapper with a
228
- short description of dev-kit and a pointer to the installed `dev-kit` skill.
229
- When the root `package.json` declares `vite-plus` directly, the wrapper also
230
- includes Vite+'s installed `node_modules/vite-plus/AGENTS.md` instructions.
231
- Transitive installations do not opt a project in.
232
+ `setup.agentInstructions` manages marked sections in the project-root
233
+ `AGENTS.md`, preserving handwritten project guidance around them. The Dev Kit
234
+ section contains a short description and a pointer to the installed `dev-kit`
235
+ skill. When the root `package.json` declares `vite-plus` directly, Dev Kit
236
+ renders its own repository-specific Vite+ guidance, including the unified
237
+ toolchain overview, help and documentation entry points, and `vp env doctor`
238
+ troubleshooting. It does not import Vite+'s generic `AGENTS.md`, which can
239
+ conflict with the repository's exact commands; transitive installations do not
240
+ opt a project in. Previously managed Vite+ sections are removed during a safe
241
+ owned update. Ambiguous or malformed managed markers fail closed.
242
+
243
+ The Dev Kit section also renders an opinionated project command policy. A
244
+ direct Vite+ dependency makes `vp` the only supported front door: built-in
245
+ format, lint, and test commands use `vp`, while repository tasks and package
246
+ scripts use `vp run`. When Dev Kit manages the quality config, the canonical
247
+ full validation and typecheck commands are `vp run check` and
248
+ `vp run typecheck`; `vp check` alone is only the Vite+ static-check command.
249
+ Without Vite+, Bun is the required package-script runner and Dev Kit lists only
250
+ quality scripts the root package actually declares. The package manager named
251
+ by `package.json#packageManager`, or inferred from a single recognized root
252
+ lockfile, is used only for dependency-install guidance. The policy forbids
253
+ switching script runners or bypassing project entry points with raw `tsc`,
254
+ test-runner, linter, or formatter commands.
232
255
 
233
256
  `setup.claudeInstructions` manages `CLAUDE.md` as the relative symlink
234
- `CLAUDE.md → AGENTS.md`. It can link to the generated wrapper in the same apply,
257
+ `CLAUDE.md → AGENTS.md`. It can link to the section-managed file in the same apply,
235
258
  or retain the older behavior of linking to an existing regular `AGENTS.md` when
236
- the wrapper task is disabled. Both outputs are recorded independently in the
237
- lockfile and local ownership state. Dev Kit refuses to replace unowned files
238
- and removes only unchanged owned outputs.
259
+ the section task is disabled. Both outputs are recorded independently in the
260
+ lockfile and local ownership state. Disabling agent instructions removes only
261
+ unchanged managed sections and deletes `AGENTS.md` only when no handwritten
262
+ content remains.
239
263
 
240
264
  ## Vite+ Git hooks
241
265
 
@@ -351,10 +375,11 @@ scripts, or TypeScript topology. It performs one frozen, script-suppressed
351
375
  install, runs `dev-kit apply --locked`, and only then runs custom preparation,
352
376
  formatting, linting, tests, and typechecking. Its default typecheck command is
353
377
  `vp run typecheck`; `workflow.typecheck` replaces it. Vite+ maps install flags
354
- to the detected package manager. The template pins an exact `setup-vp` release
355
- commit because its `v1` tag is frozen; keep it current with Renovate or
356
- Dependabot. Existing workflows remain user-owned until their rendered content
357
- matches exactly—Dev Kit never merges YAML. See the primary
378
+ to the detected package manager. The template installs the consumer's declared
379
+ Bun version and pins exact `setup-bun` and `setup-vp` releases; the latter's
380
+ `v1` tag is frozen. Keep both current with Renovate or Dependabot. Existing
381
+ workflows remain user-owned until their rendered content matches exactly—Dev
382
+ Kit never merges YAML. See the primary
358
383
  [`setup-vp` versioning guidance](https://github.com/voidzero-dev/setup-vp#versioning),
359
384
  [Vite+ install guide](https://viteplus.dev/guide/install), and
360
385
  [Vite Task run guide](https://viteplus.dev/guide/run) when maintaining the
package/bin/dev-kit.mjs CHANGED
@@ -1,3 +1,17 @@
1
- #!/usr/bin/env -S node --import tsx
1
+ #!/usr/bin/env bun
2
2
 
3
- import "../src/bin/dev-kit.ts";
3
+ import semver from "semver";
4
+
5
+ const MINIMUM_BUN_VERSION = "1.3.0";
6
+ const bunVersion = globalThis.Bun?.version;
7
+
8
+ if (bunVersion === undefined) {
9
+ throw new Error(
10
+ `Dev Kit requires the Bun runtime, version ${MINIMUM_BUN_VERSION} or newer. Install Bun and run \`dev-kit\` again.`,
11
+ );
12
+ }
13
+ if (!semver.satisfies(bunVersion, `>=${MINIMUM_BUN_VERSION}`)) {
14
+ throw new Error(`Dev Kit requires Bun ${MINIMUM_BUN_VERSION} or newer; found ${bunVersion}.`);
15
+ }
16
+
17
+ await import("../src/bin/dev-kit.ts");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieljvdm/dev-kit",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Declarative project development toolkit with portable agent skills.",
6
6
  "license": "MIT",
@@ -65,15 +65,15 @@
65
65
  "catalog:check": "./bin/dev-kit.mjs catalog verify"
66
66
  },
67
67
  "dependencies": {
68
- "@effect/platform-node": "4.0.0-beta.102",
68
+ "@effect/platform-bun": "4.0.0-beta.102",
69
69
  "@stylistic/eslint-plugin": "5.10.0",
70
70
  "effect": "4.0.0-beta.102",
71
71
  "jsonc-parser": "3.3.1",
72
- "semver": "7.8.5",
73
- "tsx": "4.22.4"
72
+ "semver": "7.8.5"
74
73
  },
75
74
  "devDependencies": {
76
75
  "@changesets/cli": "3.0.0-next.10",
76
+ "@effect/platform-node": "4.0.0-beta.102",
77
77
  "@effect/tsgo": "0.24.3",
78
78
  "@effect/vitest": "4.0.0-beta.102",
79
79
  "@types/node": "25.9.1",
@@ -101,7 +101,7 @@
101
101
  }
102
102
  },
103
103
  "engines": {
104
- "node": ">=22.12.0"
104
+ "bun": ">=1.3.0"
105
105
  },
106
106
  "packageManager": "bun@1.3.14"
107
107
  }
@@ -68,7 +68,7 @@
68
68
  "required": ["include"],
69
69
  "$defs": {
70
70
  "agentInstructionsSetup": {
71
- "description": "Manage a project-root AGENTS.md wrapper with dev-kit guidance and conditional tool instructions.",
71
+ "description": "Manage marked sections in project-root AGENTS.md with dev-kit guidance and conditional tool instructions while preserving handwritten content.",
72
72
  "type": "object",
73
73
  "additionalProperties": false,
74
74
  "properties": {
@@ -95,14 +95,30 @@ use symlinks for additional harness discovery paths. Keep every target path
95
95
  project-relative and separate from the manifest, lock, state, and process-lock
96
96
  paths.
97
97
 
98
- Enable `setup.agentInstructions` to manage a project-root `AGENTS.md` wrapper
99
- that points agents back to this skill. When `vite-plus` is a declared direct
100
- dependency, dev-kit includes its installed agent instructions in the wrapper.
98
+ Enable `setup.agentInstructions` to manage marked sections in a project-root
99
+ `AGENTS.md` while preserving handwritten project guidance. The Dev Kit section
100
+ points agents back to this skill. When `vite-plus` is a declared direct
101
+ dependency, dev-kit synthesizes repository-specific Vite+ guidance inside its
102
+ own section instead of importing Vite+'s generic `AGENTS.md`. Preserve the
103
+ useful unified-toolchain overview, help and documentation entry points, and
104
+ `vp env doctor` troubleshooting without duplicating generic commands that can
105
+ contradict the repository policy. Treat duplicate, overlapping, reversed, or
106
+ unmatched managed markers as a conflict rather than guessing which content Dev
107
+ Kit owns; remove a legacy owned Vite+ section during migration.
108
+ The managed section also publishes the repository's command authority. Direct
109
+ Vite+ projects must use `vp` built-ins and `vp run <task>`; managed quality
110
+ projects use `vp run check` for the complete format/lint/test/typecheck suite
111
+ and `vp run typecheck` for the Effect-patched compiler. Non-Vite+ projects run
112
+ existing root quality scripts through `bun run`; package-manager metadata and
113
+ lockfiles affect dependency-install guidance only. Never substitute another
114
+ script runner or call raw `tsc`, test, lint, or format binaries when a project
115
+ command exists.
101
116
  Enable `setup.claudeInstructions` when Claude Code should consume the same
102
117
  project-root instructions; it manages `CLAUDE.md` as a relative symlink to the
103
- wrapper or to an existing regular `AGENTS.md`. Preserve conflicting paths;
104
- when disabled, dev-kit removes only unchanged outputs recorded in local
105
- ownership state.
118
+ section-managed file or to an existing regular `AGENTS.md`. Preserve
119
+ conflicting paths; when disabled, dev-kit removes only unchanged marked
120
+ sections recorded in local ownership state and leaves handwritten content in
121
+ place.
106
122
 
107
123
  Enable `setup.vitePlus.hooks` when an installed direct `vite-plus` dependency
108
124
  should manage Git hooks. Each apply checks the local `.vite-hooks/_` dispatcher,
@@ -129,9 +145,11 @@ builds custom. Workflow-only consumers may configure `workflow.beforeChecks`
129
145
  and `workflow.typecheck`; treat these commands as trusted manifest input.
130
146
 
131
147
  The workflow must use one frozen, script-suppressed install, then locked Dev Kit
132
- convergence before preparation or checks. Keep `setup-vp` pinned to a reviewed
133
- release commit—the `v1` tag is frozen—and let Vite+ resolve the consumer's
134
- compatible locked version.
148
+ convergence before preparation or checks. Set up Bun from the consumer's
149
+ `packageManager` or `engines.bun` declaration before Vite+ setup. Keep both
150
+ `setup-bun` and `setup-vp` pinned to reviewed release commits—the `setup-vp`
151
+ `v1` tag is frozen—and let Vite+ resolve the consumer's compatible locked
152
+ version.
135
153
 
136
154
  ## Ownership and conflicts
137
155
 
@@ -157,6 +175,10 @@ Run `dev-kit gitignore` to add `.repos/` and `.dev-kit/` additively. Preview wit
157
175
  `dev-kit gitignore --dry-run`. Treat `.repos/<source-id>` as the reserved source
158
176
  checkout root.
159
177
 
178
+ Require Bun 1.3 or newer wherever Dev Kit runs. The published `dev-kit`
179
+ executable uses Bun directly and rejects Node execution; keep Bun available to
180
+ package lifecycle scripts and locked CI verification.
181
+
160
182
  For one lifecycle entry point, configure:
161
183
 
162
184
  ```jsonc
@@ -171,7 +193,10 @@ This intentionally refreshes the committed lock and owned outputs when the
171
193
  package manager installs a new Dev Kit or selected package-skill version.
172
194
  Review and commit those changes with the dependency update. Keep
173
195
  `dev-kit apply --locked` as a verification command, not the normal local
174
- lifecycle; in CI, run it only before any unlocked apply.
196
+ lifecycle; in CI, run it only before any unlocked apply. Invoke locked consumer
197
+ verification as
198
+ `bun ./node_modules/@danieljvdm/dev-kit/bin/dev-kit.mjs apply --locked` so a
199
+ package script named `dev-kit` cannot shadow the executable.
175
200
 
176
201
  ## Effect source checkout
177
202
 
@@ -263,7 +288,7 @@ in the consuming project.
263
288
 
264
289
  ## Current boundary
265
290
 
266
- Manage skill outputs, the `setup.agentInstructions` wrapper, the
291
+ Manage skill outputs, the `setup.agentInstructions` marked sections, the
267
292
  `setup.claudeInstructions` link, the `setup.vitePlus.hooks` dispatcher, the
268
293
  independently opt-in `setup.vitePlus.quality` config and GitHub workflow, the
269
294
  `setup.effectSource` checkout, and the explicit `setup.effectTsgo` task.
@@ -1,4 +1,4 @@
1
- import { NodeRuntime, NodeServices } from "@effect/platform-node";
1
+ import { BunRuntime, BunServices } from "@effect/platform-bun";
2
2
  import { Effect, Result } from "effect";
3
3
  import { Argument, CliError, Command as CliCommand, Flag } from "effect/unstable/cli";
4
4
 
@@ -399,7 +399,7 @@ const program = CliCommand.run(command, { version: DEV_KIT_VERSION }).pipe(
399
399
  ),
400
400
  ),
401
401
  Effect.scoped,
402
- Effect.provide(NodeServices.layer),
402
+ Effect.provide(BunServices.layer),
403
403
  );
404
404
 
405
- NodeRuntime.runMain(program, { disableErrorReporting: true });
405
+ BunRuntime.runMain(program, { disableErrorReporting: true });
@@ -8,6 +8,7 @@ export class ProjectPackageError extends Schema.TaggedErrorClass<ProjectPackageE
8
8
  const ProjectPackageSchema = Schema.fromJsonString(
9
9
  Schema.Struct({
10
10
  name: Schema.optional(Schema.String),
11
+ packageManager: Schema.optional(Schema.String),
11
12
  scripts: Schema.optional(Schema.Record(Schema.String, Schema.String)),
12
13
  dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
13
14
  devDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
package/src/sync.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  observePathWithRawModes,
27
27
  type ObservedPath,
28
28
  } from "./path-digest.ts";
29
- import { readDirectDependencyNames } from "./project-package.ts";
29
+ import { readDirectDependencyNames, readProjectPackage } from "./project-package.ts";
30
30
  import { acquireProjectProcessLock, PROJECT_PROCESS_LOCK_PATH } from "./project-process-lock.ts";
31
31
  import {
32
32
  AppliedStateSchema,
@@ -115,12 +115,14 @@ type SkillPlanAction =
115
115
  readonly action: "create" | "update";
116
116
  readonly desired: DesiredOutput;
117
117
  readonly observed: ObservedPath;
118
+ readonly stagedContent?: string;
118
119
  }
119
120
  | {
120
121
  readonly action: "remove";
121
122
  readonly previous: OwnershipReceipt;
122
123
  readonly destination: string;
123
124
  readonly observed: ObservedPath;
125
+ readonly stagedContent?: string;
124
126
  }
125
127
  | {
126
128
  readonly action: "unchanged";
@@ -234,6 +236,255 @@ const DEFAULT_LOCKFILE = "dev-kit.lock.json";
234
236
  const DEFAULT_STATE = ".dev-kit/state.json";
235
237
  const AGENT_INSTRUCTIONS_TEMPLATE = "templates/AGENTS.md";
236
238
  const DEV_KIT_SKILL_PATH_PLACEHOLDER = "{{DEV_KIT_SKILL_PATH}}";
239
+ const PROJECT_COMMAND_POLICY_PLACEHOLDER = "{{PROJECT_COMMAND_POLICY}}";
240
+ const AGENT_INSTRUCTION_MARKERS = [
241
+ { start: "<!-- DEV KIT START -->", end: "<!-- DEV KIT END -->" },
242
+ // Legacy Dev Kit releases copied this upstream section into AGENTS.md. Keep
243
+ // recognizing it so an owned section can be removed during migration.
244
+ { start: "<!--VITE PLUS START-->", end: "<!--VITE PLUS END-->" },
245
+ ] as const;
246
+
247
+ type ManagedInstructionRange = {
248
+ readonly start: number;
249
+ readonly end: number;
250
+ readonly content: string;
251
+ };
252
+
253
+ type ManagedInstructionInspection =
254
+ | {
255
+ readonly kind: "valid";
256
+ readonly ranges: ReadonlyArray<ManagedInstructionRange>;
257
+ readonly content?: string;
258
+ }
259
+ | { readonly kind: "invalid"; readonly reason: string };
260
+
261
+ const findOccurrences = (content: string, marker: string): ReadonlyArray<number> => {
262
+ const positions: Array<number> = [];
263
+ let offset = 0;
264
+
265
+ while (offset < content.length) {
266
+ const position = content.indexOf(marker, offset);
267
+
268
+ if (position === -1) break;
269
+ positions.push(position);
270
+ offset = position + marker.length;
271
+ }
272
+
273
+ return positions;
274
+ };
275
+
276
+ const inspectManagedInstructionSections = (content: string): ManagedInstructionInspection => {
277
+ const ranges: Array<ManagedInstructionRange> = [];
278
+
279
+ for (const markers of AGENT_INSTRUCTION_MARKERS) {
280
+ const starts = findOccurrences(content, markers.start);
281
+ const ends = findOccurrences(content, markers.end);
282
+
283
+ if (starts.length === 0 && ends.length === 0) continue;
284
+ if (
285
+ starts.length !== 1 ||
286
+ ends.length !== 1 ||
287
+ starts[0] === undefined ||
288
+ ends[0] === undefined
289
+ ) {
290
+ return {
291
+ kind: "invalid",
292
+ reason: `expected exactly one ${markers.start}/${markers.end} marker pair`,
293
+ };
294
+ }
295
+ if (starts[0] >= ends[0]) {
296
+ return { kind: "invalid", reason: `${markers.end} appears before ${markers.start}` };
297
+ }
298
+ const end = ends[0] + markers.end.length;
299
+
300
+ ranges.push({ start: starts[0], end, content: content.slice(starts[0], end) });
301
+ }
302
+ ranges.sort((left, right) => left.start - right.start);
303
+ for (let index = 1; index < ranges.length; index += 1) {
304
+ const previous = ranges[index - 1];
305
+ const current = ranges[index];
306
+
307
+ if (previous !== undefined && current !== undefined && current.start < previous.end) {
308
+ return { kind: "invalid", reason: "managed instruction marker pairs overlap" };
309
+ }
310
+ }
311
+
312
+ return {
313
+ kind: "valid",
314
+ ranges,
315
+ ...(ranges.length === 0
316
+ ? {}
317
+ : { content: `${ranges.map((range) => range.content.trim()).join("\n\n")}\n` }),
318
+ };
319
+ };
320
+
321
+ const removeManagedInstructionSections = (
322
+ content: string,
323
+ ranges: ReadonlyArray<ManagedInstructionRange>,
324
+ ): string => {
325
+ const first = ranges[0];
326
+ const last = ranges.at(-1);
327
+ const hasOnlyManagedSeparators = ranges.every((range, index) => {
328
+ const next = ranges[index + 1];
329
+
330
+ return next === undefined || /^\s*$/.test(content.slice(range.end, next.start));
331
+ });
332
+
333
+ if (first?.start === 0 && last !== undefined && hasOnlyManagedSeparators) {
334
+ let end = last.end;
335
+
336
+ if (content.startsWith("\r\n", end)) end += 2;
337
+ else if (content.startsWith("\n", end)) end += 1;
338
+
339
+ return content.slice(end);
340
+ }
341
+ let result = content;
342
+
343
+ for (const range of [...ranges].reverse()) {
344
+ result = result.slice(0, range.start) + result.slice(range.end);
345
+ }
346
+
347
+ return result;
348
+ };
349
+
350
+ const prependManagedInstructionSections = (content: string, managed: string): string => {
351
+ if (content.trim().length === 0) return managed;
352
+
353
+ return `${managed}${content}`;
354
+ };
355
+
356
+ const reconcileManagedInstructionSections = (
357
+ content: string,
358
+ inspection: Extract<ManagedInstructionInspection, { readonly kind: "valid" }>,
359
+ managed: string,
360
+ ): string =>
361
+ prependManagedInstructionSections(
362
+ removeManagedInstructionSections(content, inspection.ranges),
363
+ managed,
364
+ );
365
+
366
+ const renderVitePlusCommandPolicy = (
367
+ scripts: Readonly<Record<string, string>>,
368
+ managesQualityConfig: boolean,
369
+ ): string => {
370
+ const hasCheck = managesQualityConfig || scripts.check !== undefined;
371
+ const hasTypecheck = managesQualityConfig || scripts.typecheck !== undefined;
372
+
373
+ return [
374
+ "## Project command policy",
375
+ "",
376
+ "Vite+ is the unified toolchain and command authority for this repository. It wraps Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task behind the `vp` CLI; Vite+ is distinct from Vite.",
377
+ "",
378
+ "Run `vp help` for available commands and `vp <command> --help` for command-specific options. Documentation is available locally in `node_modules/vite-plus/docs` and online at https://viteplus.dev/guide/.",
379
+ "",
380
+ "Use these repository commands:",
381
+ "",
382
+ "- Install dependencies: `vp install`.",
383
+ ...(hasCheck ? ["- Full validation: `vp run check`."] : []),
384
+ "- Static checks: `vp check`.",
385
+ "- Format check: `vp fmt --check`; format fixes: `vp fmt`.",
386
+ "- Lint only: `vp lint`; lint fixes: `vp lint --fix`.",
387
+ "- Tests only: `vp test`.",
388
+ ...(hasTypecheck ? ["- Typecheck only: `vp run typecheck`."] : []),
389
+ "- Other repository tasks and package scripts: `vp run <task>`.",
390
+ "- Toolchain or runtime troubleshooting: run `vp env doctor` and include its output when asking for help.",
391
+ "",
392
+ "Do not use `bun run`, `npm run`, `pnpm run`, or `yarn run` in this repository. Do not invoke underlying tools such as `tsc`, `vitest`, `oxlint`, or `oxfmt` directly; use the Vite+ entry points above.",
393
+ ].join("\n");
394
+ };
395
+
396
+ const PACKAGE_MANAGER_COMMANDS = {
397
+ bun: { install: "bun install", label: "Bun" },
398
+ npm: { install: "npm install", label: "npm" },
399
+ pnpm: { install: "pnpm install", label: "pnpm" },
400
+ yarn: { install: "yarn install", label: "Yarn" },
401
+ } as const;
402
+
403
+ type PackageManagerName = keyof typeof PACKAGE_MANAGER_COMMANDS;
404
+
405
+ const packageManagerName = (declaration: string | undefined): PackageManagerName | undefined => {
406
+ const name = declaration?.split("@", 1)[0];
407
+
408
+ return name !== undefined && name in PACKAGE_MANAGER_COMMANDS
409
+ ? (name as PackageManagerName)
410
+ : undefined;
411
+ };
412
+
413
+ const detectPackageManager = Effect.fn("detectPackageManager")(function* (
414
+ projectDir: string,
415
+ declaration: string | undefined,
416
+ ) {
417
+ const declared = packageManagerName(declaration);
418
+
419
+ if (declared !== undefined || declaration !== undefined) return declared;
420
+ const fs = yield* FileSystem.FileSystem;
421
+ const path = yield* Path.Path;
422
+ const lockfiles: ReadonlyArray<readonly [PackageManagerName, ReadonlyArray<string>]> = [
423
+ ["bun", ["bun.lock", "bun.lockb"]],
424
+ ["npm", ["package-lock.json", "npm-shrinkwrap.json"]],
425
+ ["pnpm", ["pnpm-lock.yaml"]],
426
+ ["yarn", ["yarn.lock"]],
427
+ ];
428
+ const detected: Array<PackageManagerName> = [];
429
+
430
+ for (const [manager, files] of lockfiles) {
431
+ let found = false;
432
+
433
+ for (const file of files) {
434
+ if (yield* fs.exists(path.join(projectDir, file))) {
435
+ found = true;
436
+ break;
437
+ }
438
+ }
439
+ if (found) {
440
+ detected.push(manager);
441
+ }
442
+ }
443
+
444
+ return detected.length === 1 ? detected[0] : undefined;
445
+ });
446
+
447
+ const renderPackageScriptCommandPolicy = (
448
+ manager: PackageManagerName | undefined,
449
+ scripts: Readonly<Record<string, string>>,
450
+ ): string => {
451
+ const installer = manager === undefined ? undefined : PACKAGE_MANAGER_COMMANDS[manager];
452
+ const entries = [
453
+ ["check", "Full validation"],
454
+ ["format:check", "Format check"],
455
+ ["format", "Format"],
456
+ ["lint", "Lint"],
457
+ ["test", "Tests"],
458
+ ["typecheck", "Typecheck"],
459
+ ] as const;
460
+ const commands = entries.flatMap(([script, label]) =>
461
+ scripts[script] === undefined ? [] : [`- ${label}: \`bun run ${script}\`.`],
462
+ );
463
+ const knownScripts = new Set(entries.map(([script]) => script));
464
+ const additionalCommands = Object.keys(scripts)
465
+ .filter(
466
+ (script) =>
467
+ !knownScripts.has(script as (typeof entries)[number][0]) &&
468
+ /^(?:check|validate|fmt|format|lint|test|type-?check)(?::|$)/.test(script),
469
+ )
470
+ .sort()
471
+ .map((script) => `- Script \`${script}\`: \`bun run ${script}\`.`);
472
+ const qualityCommands = [...commands, ...additionalCommands];
473
+
474
+ return [
475
+ "## Project command policy",
476
+ "",
477
+ "Bun is the package-script runner for this repository:",
478
+ "",
479
+ ...(installer === undefined
480
+ ? []
481
+ : [`- Install dependencies with ${installer.label}: \`${installer.install}\`.`]),
482
+ ...qualityCommands,
483
+ ...(qualityCommands.length === 0 ? ["- No root quality scripts are currently declared."] : []),
484
+ "",
485
+ "Run only declared scripts through `bun run <script>`. Do not use `npm run`, `pnpm run`, or `yarn run`, invent missing scripts, or invoke underlying tools such as `tsc`, `vitest`, `eslint`, or `prettier` directly. The Bun script-runner requirement does not choose the package manager used to install dependencies.",
486
+ ].join("\n");
487
+ };
237
488
 
238
489
  const resolvePackageRoot = Effect.fn("resolvePackageRoot")(function* () {
239
490
  const path = yield* Path.Path;
@@ -521,6 +772,7 @@ const renderAgentInstructions = Effect.fn("renderAgentInstructions")(function* (
521
772
  packageRoot: string,
522
773
  projectDir: string,
523
774
  sourceBySkill: ReadonlyMap<string, ResolvedSkillSource>,
775
+ managesVitePlusQuality: boolean,
524
776
  ) {
525
777
  const fs = yield* FileSystem.FileSystem;
526
778
  const path = yield* Path.Path;
@@ -538,6 +790,11 @@ const renderAgentInstructions = Effect.fn("renderAgentInstructions")(function* (
538
790
  message: `dev-kit agent instructions template is missing ${DEV_KIT_SKILL_PATH_PLACEHOLDER}`,
539
791
  });
540
792
  }
793
+ if (!template.includes(PROJECT_COMMAND_POLICY_PLACEHOLDER)) {
794
+ return yield* new InvalidProjectStateError({
795
+ message: `dev-kit agent instructions template is missing ${PROJECT_COMMAND_POLICY_PLACEHOLDER}`,
796
+ });
797
+ }
541
798
 
542
799
  const devKitSkill = sourceBySkill.get("dev-kit");
543
800
  const devKitSkillPath =
@@ -550,21 +807,22 @@ const renderAgentInstructions = Effect.fn("renderAgentInstructions")(function* (
550
807
  path.join(devKitSkill.linkPath ?? devKitSkill.path, "SKILL.md"),
551
808
  ),
552
809
  );
553
- const sections = [template.replaceAll(DEV_KIT_SKILL_PATH_PLACEHOLDER, devKitSkillPath).trimEnd()];
554
-
555
- if ((yield* readDirectDependencyNames(projectDir)).includes("vite-plus")) {
556
- const vitePlusTemplate = path.join(projectDir, "node_modules", "vite-plus", "AGENTS.md");
557
-
558
- if ((yield* observePath(vitePlusTemplate)).kind !== "file") {
559
- return yield* new InvalidProjectStateError({
560
- message:
561
- "Vite+ is a direct dependency but its agent instructions are not a regular file: node_modules/vite-plus/AGENTS.md",
562
- });
563
- }
564
- sections.push((yield* fs.readFileString(vitePlusTemplate)).trim());
565
- }
566
-
567
- return `${sections.join("\n\n")}\n`;
810
+ const usesVitePlus = (yield* readDirectDependencyNames(projectDir)).includes("vite-plus");
811
+ const projectPackage = yield* readProjectPackage(projectDir).pipe(
812
+ Effect.catchTag("ProjectPackageError", (error) =>
813
+ error.message.startsWith("package.json not found:") ? Effect.void : Effect.fail(error),
814
+ ),
815
+ );
816
+ const manager = yield* detectPackageManager(projectDir, projectPackage?.packageManager);
817
+ const commandPolicy = usesVitePlus
818
+ ? renderVitePlusCommandPolicy(projectPackage?.scripts ?? {}, managesVitePlusQuality)
819
+ : renderPackageScriptCommandPolicy(manager, projectPackage?.scripts ?? {});
820
+ const devKitInstructions = template
821
+ .replaceAll(DEV_KIT_SKILL_PATH_PLACEHOLDER, devKitSkillPath)
822
+ .replaceAll(PROJECT_COMMAND_POLICY_PLACEHOLDER, commandPolicy)
823
+ .trimEnd();
824
+
825
+ return `${devKitInstructions}\n`;
568
826
  });
569
827
 
570
828
  const readGeneratedFileTemplate = Effect.fn("readGeneratedFileTemplate")(function* (
@@ -597,7 +855,12 @@ const buildDesiredOutputs = Effect.fn("buildDesiredSkillOutputs")(function* (
597
855
 
598
856
  if (setup.agentInstructions.enabled) {
599
857
  const managed = yield* resolveManagedPath(projectDir, "AGENTS.md");
600
- const content = yield* renderAgentInstructions(packageRoot, projectDir, sourceBySkill);
858
+ const content = yield* renderAgentInstructions(
859
+ packageRoot,
860
+ projectDir,
861
+ sourceBySkill,
862
+ setup.vitePlus.quality.config.enabled,
863
+ );
601
864
 
602
865
  outputs.push({
603
866
  resourceId: "setup:agent-instructions",
@@ -662,7 +925,7 @@ const buildDesiredOutputs = Effect.fn("buildDesiredSkillOutputs")(function* (
662
925
  devKitCommand:
663
926
  projectDir === packageRoot
664
927
  ? "./bin/dev-kit.mjs apply --locked"
665
- : "vp exec dev-kit apply --locked",
928
+ : "bun ./node_modules/@danieljvdm/dev-kit/bin/dev-kit.mjs apply --locked",
666
929
  workflow: setup.vitePlus.quality.workflow,
667
930
  });
668
931
 
@@ -803,6 +1066,89 @@ const planDesiredOutputs = Effect.fn("planDesiredSkillOutputs")(function* (
803
1066
  (rawModeObservation?.kind === matchingLockedOutput.kind &&
804
1067
  rawModeObservation.digest === matchingLockedOutput.digest));
805
1068
 
1069
+ if (output.resourceId === "setup:agent-instructions" && "content" in output) {
1070
+ if (observed.kind === "missing") {
1071
+ actions.push({
1072
+ action: "create",
1073
+ desired: output,
1074
+ observed,
1075
+ stagedContent: output.content,
1076
+ });
1077
+ continue;
1078
+ }
1079
+ if (observed.kind !== "file") {
1080
+ actions.push({
1081
+ action: "conflict",
1082
+ path: output.path,
1083
+ reason: "destination is not a regular file",
1084
+ });
1085
+ continue;
1086
+ }
1087
+ const existingContent = yield* FileSystem.FileSystem.pipe(
1088
+ Effect.flatMap((fs) => fs.readFileString(output.destination)),
1089
+ );
1090
+ const inspection = inspectManagedInstructionSections(existingContent);
1091
+
1092
+ if (inspection.kind === "invalid") {
1093
+ actions.push({ action: "conflict", path: output.path, reason: inspection.reason });
1094
+ continue;
1095
+ }
1096
+ const managedDigest =
1097
+ inspection.content === undefined ? undefined : yield* digestFileContent(inspection.content);
1098
+ const receiptOwnsManaged =
1099
+ sameReceipt !== undefined &&
1100
+ (managedDigest === sameReceipt.digest || observed.digest === sameReceipt.digest);
1101
+ const lockOwnsManaged =
1102
+ matchingLockedOutput !== undefined &&
1103
+ (managedDigest === matchingLockedOutput.digest ||
1104
+ observed.digest === matchingLockedOutput.digest);
1105
+ const legacyOwnsWholeFile =
1106
+ (sameReceipt !== undefined &&
1107
+ observed.digest === sameReceipt.digest &&
1108
+ managedDigest !== sameReceipt.digest) ||
1109
+ (matchingLockedOutput !== undefined &&
1110
+ lockedOwnsObserved &&
1111
+ managedDigest !== matchingLockedOutput.digest);
1112
+
1113
+ if (managedDigest === output.digest && !legacyOwnsWholeFile) {
1114
+ if (sameReceipt !== undefined || lockOwnsManaged || lockedOwnsObserved) {
1115
+ actions.push({
1116
+ action: "unchanged",
1117
+ desired: output,
1118
+ observed,
1119
+ adopted: sameReceipt === undefined,
1120
+ });
1121
+ } else {
1122
+ actions.push({
1123
+ action: "conflict",
1124
+ path: output.path,
1125
+ reason: "managed instruction sections exist but are not owned",
1126
+ });
1127
+ }
1128
+ } else if (
1129
+ managedDigest === undefined ||
1130
+ receiptOwnsManaged ||
1131
+ lockOwnsManaged ||
1132
+ lockedOwnsObserved
1133
+ ) {
1134
+ actions.push({
1135
+ action: "update",
1136
+ desired: output,
1137
+ observed,
1138
+ stagedContent: legacyOwnsWholeFile
1139
+ ? output.content
1140
+ : reconcileManagedInstructionSections(existingContent, inspection, output.content),
1141
+ });
1142
+ } else {
1143
+ actions.push({
1144
+ action: "conflict",
1145
+ path: output.path,
1146
+ reason: "managed instruction sections exist but are not owned",
1147
+ });
1148
+ }
1149
+ continue;
1150
+ }
1151
+
806
1152
  if (observed.kind === "missing") {
807
1153
  actions.push({ action: "create", desired: output, observed });
808
1154
  } else if (observed.kind === output.kind && observed.digest === output.digest) {
@@ -839,6 +1185,46 @@ const planDesiredOutputs = Effect.fn("planDesiredSkillOutputs")(function* (
839
1185
  const observed = yield* observePath(managed.absolute);
840
1186
 
841
1187
  if (observed.kind === "missing") continue;
1188
+ if (receipt.resourceId === "setup:agent-instructions") {
1189
+ if (observed.kind !== "file") {
1190
+ actions.push({
1191
+ action: "conflict",
1192
+ path: receipt.path,
1193
+ reason: "stale owned destination is not a regular file",
1194
+ });
1195
+ continue;
1196
+ }
1197
+ const existingContent = yield* FileSystem.FileSystem.pipe(
1198
+ Effect.flatMap((fs) => fs.readFileString(managed.absolute)),
1199
+ );
1200
+ const inspection = inspectManagedInstructionSections(existingContent);
1201
+
1202
+ if (inspection.kind === "invalid") {
1203
+ actions.push({ action: "conflict", path: receipt.path, reason: inspection.reason });
1204
+ continue;
1205
+ }
1206
+ if (inspection.content === undefined) continue;
1207
+ const managedDigest = yield* digestFileContent(inspection.content);
1208
+
1209
+ if (managedDigest === receipt.digest || observed.digest === receipt.digest) {
1210
+ const remaining = removeManagedInstructionSections(existingContent, inspection.ranges);
1211
+
1212
+ actions.push({
1213
+ action: "remove",
1214
+ previous: receipt,
1215
+ destination: managed.absolute,
1216
+ observed,
1217
+ ...(remaining.trim().length === 0 ? {} : { stagedContent: remaining }),
1218
+ });
1219
+ } else {
1220
+ actions.push({
1221
+ action: "conflict",
1222
+ path: receipt.path,
1223
+ reason: "stale owned managed instruction sections were modified",
1224
+ });
1225
+ }
1226
+ continue;
1227
+ }
842
1228
  if (observed.kind === receipt.kind && observed.digest === receipt.digest) {
843
1229
  actions.push({
844
1230
  action: "remove",
@@ -1110,16 +1496,6 @@ export const planProjectSkills = Effect.fn("planProjectSkills")(function* (optio
1110
1496
  const currentLock = yield* readOptionalStructuredFile(lockManaged.absolute, DevKitLockSchema);
1111
1497
  const currentState = yield* readOptionalStructuredFile(stateManaged.absolute, AppliedStateSchema);
1112
1498
 
1113
- if (
1114
- manifest.setup.claudeInstructions.enabled &&
1115
- !manifest.setup.agentInstructions.enabled &&
1116
- currentState?.outputs.some((output) => output.resourceId === "setup:agent-instructions")
1117
- ) {
1118
- return yield* new InvalidProjectStateError({
1119
- message:
1120
- "cannot disable agentInstructions while claudeInstructions still links to its AGENTS.md wrapper",
1121
- });
1122
- }
1123
1499
  yield* validateReservedPaths(projectDir, reservedPaths, [
1124
1500
  ...(currentLock?.outputs ?? []),
1125
1501
  ...(currentState?.outputs ?? []),
@@ -1143,6 +1519,19 @@ export const planProjectSkills = Effect.fn("planProjectSkills")(function* (optio
1143
1519
  currentState,
1144
1520
  nextLock,
1145
1521
  );
1522
+ const removesClaudeInstructionsSource = planned.actions.some(
1523
+ (action) =>
1524
+ action.action === "remove" &&
1525
+ action.previous.resourceId === "setup:agent-instructions" &&
1526
+ action.stagedContent === undefined,
1527
+ );
1528
+
1529
+ if (manifest.setup.claudeInstructions.enabled && removesClaudeInstructionsSource) {
1530
+ return yield* new InvalidProjectStateError({
1531
+ message:
1532
+ "cannot disable agentInstructions while claudeInstructions still links to an AGENTS.md that would be removed",
1533
+ });
1534
+ }
1146
1535
 
1147
1536
  return {
1148
1537
  projectDir,
@@ -1310,13 +1699,28 @@ const applyPlannedSkillChanges = Effect.fn("applyPlannedSkillChanges")(function*
1310
1699
  let stageIndex = 0;
1311
1700
 
1312
1701
  for (const action of mutating) {
1313
- if (action.action === "remove") continue;
1702
+ if (action.action === "remove" && action.stagedContent === undefined) continue;
1314
1703
  const staged = path.join(stageDir, String(stageIndex++));
1315
1704
 
1316
1705
  yield* fs.makeDirectory(path.dirname(staged), { recursive: true });
1317
- if (action.desired.mode === "copy") {
1706
+ if (action.stagedContent !== undefined && action.observed.kind === "file") {
1707
+ const destination =
1708
+ action.action === "remove" ? action.destination : action.desired.destination;
1709
+
1710
+ yield* fs.copy(destination, staged, { overwrite: true });
1711
+ yield* fs.writeFileString(staged, action.stagedContent);
1712
+ } else if (action.action === "remove") {
1713
+ if (action.stagedContent === undefined) {
1714
+ return yield* new InvalidProjectStateError({
1715
+ message: `missing staged content for ${action.previous.resourceId}`,
1716
+ });
1717
+ }
1718
+ yield* fs.writeFileString(staged, action.stagedContent, { mode: 0o644 });
1719
+ } else if (action.desired.mode === "copy") {
1318
1720
  if (action.desired.kind === "file") {
1319
- yield* fs.writeFileString(staged, action.desired.content, { mode: 0o644 });
1721
+ yield* fs.writeFileString(staged, action.stagedContent ?? action.desired.content, {
1722
+ mode: 0o644,
1723
+ });
1320
1724
  } else {
1321
1725
  yield* fs.copy(action.desired.source, staged, { overwrite: true });
1322
1726
  const symbolicLink = yield* findNestedSymbolicLink(staged);
@@ -1330,14 +1734,35 @@ const applyPlannedSkillChanges = Effect.fn("applyPlannedSkillChanges")(function*
1330
1734
  } else {
1331
1735
  yield* fs.symlink(action.desired.linkTarget, staged);
1332
1736
  }
1333
- const observation = yield* observePath(staged);
1334
-
1335
- if (observation.kind !== action.desired.kind || observation.digest !== action.desired.digest) {
1336
- return yield* new InvalidProjectStateError({
1337
- message: `staged output digest mismatch for ${action.desired.path}`,
1338
- });
1737
+ if (action.action !== "remove") {
1738
+ const observation = yield* observePath(staged);
1739
+
1740
+ if (action.desired.resourceId === "setup:agent-instructions") {
1741
+ const content = yield* fs.readFileString(staged);
1742
+ const inspection = inspectManagedInstructionSections(content);
1743
+ const digest =
1744
+ inspection.kind === "valid" && inspection.content !== undefined
1745
+ ? yield* digestFileContent(inspection.content)
1746
+ : undefined;
1747
+
1748
+ if (observation.kind !== "file" || digest !== action.desired.digest) {
1749
+ return yield* new InvalidProjectStateError({
1750
+ message: `staged output digest mismatch for ${action.desired.path}`,
1751
+ });
1752
+ }
1753
+ } else if (
1754
+ observation.kind !== action.desired.kind ||
1755
+ observation.digest !== action.desired.digest
1756
+ ) {
1757
+ return yield* new InvalidProjectStateError({
1758
+ message: `staged output digest mismatch for ${action.desired.path}`,
1759
+ });
1760
+ }
1339
1761
  }
1340
- stagedByResource.set(action.desired.resourceId, staged);
1762
+ stagedByResource.set(
1763
+ action.action === "remove" ? action.previous.resourceId : action.desired.resourceId,
1764
+ staged,
1765
+ );
1341
1766
  }
1342
1767
 
1343
1768
  yield* verifyPackageSkillSources(plan);
@@ -1360,11 +1785,18 @@ const applyPlannedSkillChanges = Effect.fn("applyPlannedSkillChanges")(function*
1360
1785
 
1361
1786
  for (const action of mutating) {
1362
1787
  const staged =
1363
- action.action === "remove" ? undefined : stagedByResource.get(action.desired.resourceId);
1788
+ action.action === "remove"
1789
+ ? stagedByResource.get(action.previous.resourceId)
1790
+ : stagedByResource.get(action.desired.resourceId);
1364
1791
 
1365
- if (action.action !== "remove" && staged === undefined) {
1792
+ if (
1793
+ (action.action !== "remove" || action.stagedContent !== undefined) &&
1794
+ staged === undefined
1795
+ ) {
1366
1796
  return yield* new InvalidProjectStateError({
1367
- message: `missing staged output for ${action.desired.resourceId}`,
1797
+ message: `missing staged output for ${
1798
+ action.action === "remove" ? action.previous.resourceId : action.desired.resourceId
1799
+ }`,
1368
1800
  });
1369
1801
  }
1370
1802
  replacements.push({
@@ -32,7 +32,8 @@ export type VitePlusQualitySelection = {
32
32
  };
33
33
 
34
34
  const SINGLE_PROJECT_TYPECHECK_TASK = ' typecheck: "tsc --noEmit",';
35
- const LOCKED_DEV_KIT_COMMAND = "vp exec dev-kit apply --locked";
35
+ const LOCKED_DEV_KIT_COMMAND =
36
+ "bun ./node_modules/@danieljvdm/dev-kit/bin/dev-kit.mjs apply --locked";
36
37
  const BEFORE_CHECKS_MARKER =
37
38
  " # Dev Kit inserts configured quality.workflow.beforeChecks steps here.\n\n";
38
39
  const DEFAULT_WORKFLOW_TYPECHECK = ` - name: Type check with Effect TypeScript-Go
@@ -6,4 +6,6 @@ This project uses `@danieljvdm/dev-kit` to manage portable agent skills and repr
6
6
 
7
7
  For dev-kit operations, use the `dev-kit` skill and read `{{DEV_KIT_SKILL_PATH}}` before changing managed outputs.
8
8
 
9
+ {{PROJECT_COMMAND_POLICY}}
10
+
9
11
  <!-- DEV KIT END -->
@@ -22,6 +22,10 @@ jobs:
22
22
  with:
23
23
  persist-credentials: false
24
24
 
25
+ # setup-bun resolves the consumer's packageManager or engines.bun version.
26
+ - name: Set up Bun
27
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # setup-bun action v2.2.0
28
+
25
29
  # setup-vp's v1 tag is frozen. Keep this v1.16.1 commit current with
26
30
  # Renovate or Dependabot so fixes arrive through reviewed pull requests.
27
31
  - name: Set up Vite+ and install dependencies
@@ -34,7 +38,7 @@ jobs:
34
38
  args: ["--frozen-lockfile", "--ignore-scripts"]
35
39
 
36
40
  - name: Verify locked Dev Kit setup
37
- run: vp exec dev-kit apply --locked
41
+ run: bun ./node_modules/@danieljvdm/dev-kit/bin/dev-kit.mjs apply --locked
38
42
 
39
43
  # Dev Kit inserts configured quality.workflow.beforeChecks steps here.
40
44