plumbbob 0.8.13 → 0.8.17

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 (93) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/lib/agents.js +1 -1
  3. package/dist/lib/check.js +9 -6
  4. package/dist/lib/intent.js +20 -0
  5. package/dist/lib/orient.js +4 -3
  6. package/dist/verbs/checkpoint.js +30 -11
  7. package/dist/verbs/finish.js +24 -8
  8. package/node_modules/checkride/CHANGELOG.md +234 -0
  9. package/node_modules/checkride/README.md +134 -31
  10. package/node_modules/checkride/dist/adapters.d.ts +50 -1
  11. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  12. package/node_modules/checkride/dist/adapters.js +123 -7
  13. package/node_modules/checkride/dist/adapters.js.map +1 -1
  14. package/node_modules/checkride/dist/agent-setup/hook.d.ts.map +1 -1
  15. package/node_modules/checkride/dist/agent-setup/hook.js +15 -1
  16. package/node_modules/checkride/dist/agent-setup/hook.js.map +1 -1
  17. package/node_modules/checkride/dist/agent-setup/index.d.ts +0 -1
  18. package/node_modules/checkride/dist/agent-setup/index.d.ts.map +1 -1
  19. package/node_modules/checkride/dist/agent-setup/index.js.map +1 -1
  20. package/node_modules/checkride/dist/baseline/fallow.d.ts +64 -0
  21. package/node_modules/checkride/dist/baseline/fallow.d.ts.map +1 -0
  22. package/node_modules/checkride/dist/baseline/fallow.js +256 -0
  23. package/node_modules/checkride/dist/baseline/fallow.js.map +1 -0
  24. package/node_modules/checkride/dist/baseline/fingerprint.d.ts +4 -6
  25. package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -1
  26. package/node_modules/checkride/dist/baseline/fingerprint.js +9 -4
  27. package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -1
  28. package/node_modules/checkride/dist/baseline/index.d.ts +4 -5
  29. package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -1
  30. package/node_modules/checkride/dist/baseline/index.js +2 -2
  31. package/node_modules/checkride/dist/baseline/index.js.map +1 -1
  32. package/node_modules/checkride/dist/{baseline/command.d.ts → baseline-command.d.ts} +11 -9
  33. package/node_modules/checkride/dist/baseline-command.d.ts.map +1 -0
  34. package/node_modules/checkride/dist/{baseline/command.js → baseline-command.js} +9 -7
  35. package/node_modules/checkride/dist/baseline-command.js.map +1 -0
  36. package/node_modules/checkride/dist/cli.d.ts.map +1 -1
  37. package/node_modules/checkride/dist/cli.js +95 -8
  38. package/node_modules/checkride/dist/cli.js.map +1 -1
  39. package/node_modules/checkride/dist/config.d.ts +37 -12
  40. package/node_modules/checkride/dist/config.d.ts.map +1 -1
  41. package/node_modules/checkride/dist/config.js +268 -60
  42. package/node_modules/checkride/dist/config.js.map +1 -1
  43. package/node_modules/checkride/dist/digest/digest.d.ts +0 -6
  44. package/node_modules/checkride/dist/digest/digest.d.ts.map +1 -1
  45. package/node_modules/checkride/dist/digest/digest.js +3 -3
  46. package/node_modules/checkride/dist/digest/digest.js.map +1 -1
  47. package/node_modules/checkride/dist/digest/index.d.ts +1 -2
  48. package/node_modules/checkride/dist/digest/index.d.ts.map +1 -1
  49. package/node_modules/checkride/dist/digest/index.js +1 -1
  50. package/node_modules/checkride/dist/digest/index.js.map +1 -1
  51. package/node_modules/checkride/dist/doctor.d.ts +14 -1
  52. package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
  53. package/node_modules/checkride/dist/doctor.js +124 -55
  54. package/node_modules/checkride/dist/doctor.js.map +1 -1
  55. package/node_modules/checkride/dist/init.d.ts +6 -0
  56. package/node_modules/checkride/dist/init.d.ts.map +1 -1
  57. package/node_modules/checkride/dist/init.js +347 -121
  58. package/node_modules/checkride/dist/init.js.map +1 -1
  59. package/node_modules/checkride/dist/links.d.ts.map +1 -1
  60. package/node_modules/checkride/dist/links.js +8 -9
  61. package/node_modules/checkride/dist/links.js.map +1 -1
  62. package/node_modules/checkride/dist/orchestrator.d.ts +56 -1
  63. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  64. package/node_modules/checkride/dist/orchestrator.js +506 -138
  65. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  66. package/node_modules/checkride/dist/pack.d.ts +76 -0
  67. package/node_modules/checkride/dist/pack.d.ts.map +1 -0
  68. package/node_modules/checkride/dist/pack.js +217 -0
  69. package/node_modules/checkride/dist/pack.js.map +1 -0
  70. package/node_modules/checkride/dist/pm/translate.d.ts +18 -10
  71. package/node_modules/checkride/dist/pm/translate.d.ts.map +1 -1
  72. package/node_modules/checkride/dist/pm/translate.js +27 -13
  73. package/node_modules/checkride/dist/pm/translate.js.map +1 -1
  74. package/node_modules/checkride/dist/smoke.d.ts +86 -0
  75. package/node_modules/checkride/dist/smoke.d.ts.map +1 -0
  76. package/node_modules/checkride/dist/smoke.js +346 -0
  77. package/node_modules/checkride/dist/smoke.js.map +1 -0
  78. package/node_modules/checkride/dist/snippets.d.ts +215 -0
  79. package/node_modules/checkride/dist/snippets.d.ts.map +1 -0
  80. package/node_modules/checkride/dist/snippets.js +360 -0
  81. package/node_modules/checkride/dist/snippets.js.map +1 -0
  82. package/node_modules/checkride/package.json +4 -2
  83. package/node_modules/checkride/schema/checkride.config.schema.json +11 -6
  84. package/package.json +3 -2
  85. package/skills/pb-build/SKILL.md +7 -0
  86. package/skills/pb-park/SKILL.md +3 -2
  87. package/skills/pb-plan/SKILL.md +30 -9
  88. package/skills/pb-refine/SKILL.md +21 -6
  89. package/skills/pb-step/SKILL.md +10 -5
  90. package/skills/pb-verify/SKILL.md +12 -1
  91. package/templates/intent.md +55 -11
  92. package/node_modules/checkride/dist/baseline/command.d.ts.map +0 -1
  93. package/node_modules/checkride/dist/baseline/command.js.map +0 -1
@@ -12,14 +12,17 @@
12
12
  * it afterward.
13
13
  */
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
15
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
16
16
  import { basename, dirname, join } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { ADAPTERS, SLOTS } from './adapters.js';
19
+ import { planSnippets, selectDocFiles } from './snippets.js';
19
20
  import { CLAUDE_SETTINGS_FILE, writeStopHook } from './agent-setup/index.js';
20
- import { BASELINE_FILE, isFingerprintable, runBaseline } from './baseline/index.js';
21
+ import { BASELINE_FILE, isFingerprintable } from './baseline/index.js';
22
+ import { runBaseline } from './baseline-command.js';
21
23
  import { configSchemaUrl, resolveChecks } from './config.js';
22
24
  import { runChecks } from './orchestrator.js';
25
+ import { detectPackageManager } from './pm/index.js';
23
26
  const NULL_OUT = { write: () => true };
24
27
  /** Run the adopted checks once; a failing (non-skipped) check marks its slot. */
25
28
  async function defaultProbe(slots, cwd) {
@@ -297,11 +300,10 @@ async function writeSharedStatic(w) {
297
300
  ['shared/gitignore', '.gitignore'],
298
301
  ['shared/npmrc', '.npmrc'],
299
302
  ];
300
- for (const [from, to] of map)
301
- await put(w, to, readTemplate(from));
302
- for (const rule of ['no-class.yml', 'no-default-export.yml', 'no-deep-sibling-import.yml', 'require-js-extension.yml']) {
303
- await put(w, join('rules', rule), readTemplate(join('shared', 'rules', rule)));
304
- }
303
+ // Independent writes to distinct paths — run them concurrently.
304
+ await Promise.all(map.map(([from, to]) => put(w, to, readTemplate(from))));
305
+ const rules = ['no-class.yml', 'no-default-export.yml', 'no-deep-sibling-import.yml', 'require-js-extension.yml'];
306
+ await Promise.all(rules.map((rule) => put(w, join('rules', rule), readTemplate(join('shared', 'rules', rule)))));
305
307
  }
306
308
  async function writePackage(w, dir, pkgName, value) {
307
309
  await put(w, join(dir, 'package.json'), packageJsonFor(pkgName));
@@ -327,47 +329,97 @@ async function writeHook(w, hook, skipped) {
327
329
  // New-project mode
328
330
  // ----------------------------------------------------------------------------
329
331
  const DEFAULT_ACTIVE_SLOTS = ['types', 'lint', 'struct', 'dead', 'test', 'docs', 'links', 'spell'];
330
- async function initNew(options, cwd) {
331
- const shape = options.shape ?? 'flat';
332
- const name = options.name ?? (basename(cwd) || 'app');
333
- const scope = options.scope ?? null;
334
- const license = options.license ?? 'MIT';
335
- const author = options.author ?? null;
336
- const fullName = scope ? `${scope}/${name}` : name;
337
- const adapters = options.adapters ?? ADAPTERS;
338
- const slots = options.slots ?? SLOTS;
339
- const checkrideSpec = options.checkrideSpec ?? `^${productVersion()}`;
340
- const w = { cwd, dryRun: options.dryRun ?? false, written: [] };
332
+ /** Emit every scaffold file for the shape into `w` (records paths; obeys dryRun). */
333
+ async function writeNewScaffold(w, s) {
341
334
  await writeSharedStatic(w);
342
- await put(w, 'tsconfig.json', render(readTemplate(join(shape, 'tsconfig.json')), { name }));
343
- await put(w, 'fallow.toml', render(readTemplate(join(shape, 'fallow.toml')), { name }));
344
- await put(w, 'pnpm-workspace.yaml', render(readTemplate(join(shape, 'pnpm-workspace.yaml')), { name }));
345
- await put(w, 'cspell.json', cspellWithName(name, scope));
346
- await put(w, 'package.json', rootPackageJson(fullName, license, author, collectDevDeps(adapters, slots, checkrideSpec), shape !== 'flat'));
347
- await put(w, 'LICENSE', licenseText(license, author ?? fullName));
348
- await put(w, 'README.md', readme(name));
335
+ await put(w, 'tsconfig.json', render(readTemplate(join(s.shape, 'tsconfig.json')), { name: s.name }));
336
+ await put(w, 'fallow.toml', render(readTemplate(join(s.shape, 'fallow.toml')), { name: s.name }));
337
+ await put(w, 'pnpm-workspace.yaml', render(readTemplate(join(s.shape, 'pnpm-workspace.yaml')), { name: s.name }));
338
+ await put(w, 'cspell.json', cspellWithName(s.name, s.scope));
339
+ await put(w, 'package.json', rootPackageJson(s.fullName, s.license, s.author, collectDevDeps(s.adapters, s.slots, s.checkrideSpec), s.shape !== 'flat'));
340
+ await put(w, 'LICENSE', licenseText(s.license, s.author ?? s.fullName));
341
+ await put(w, 'README.md', readme(s.name));
349
342
  await put(w, 'AGENTS.md', agentsMd(DEFAULT_ACTIVE_SLOTS));
350
343
  await put(w, 'CLAUDE.md', claudeMd());
351
- if (shape === 'flat') {
352
- await put(w, join('src', 'index.ts'), sourceModule(name));
353
- await put(w, join('src', 'index.test.ts'), smokeTest(name));
344
+ if (s.shape === 'flat') {
345
+ await put(w, join('src', 'index.ts'), sourceModule(s.name));
346
+ await put(w, join('src', 'index.test.ts'), smokeTest(s.name));
354
347
  }
355
- else if (shape === 'monorepo') {
356
- await writePackage(w, join('libs', 'core'), scope ? `${scope}/core` : 'core', 'core');
357
- await writePackage(w, join('apps', name), fullName, name);
348
+ else if (s.shape === 'monorepo') {
349
+ await writePackage(w, join('libs', 'core'), s.scope ? `${s.scope}/core` : 'core', 'core');
350
+ await writePackage(w, join('apps', s.name), s.fullName, s.name);
358
351
  }
359
352
  else {
360
- await put(w, join('src', 'index.ts'), sourceModule(name));
361
- await put(w, join('src', 'index.test.ts'), smokeTest(name));
362
- await writePackage(w, join('packages', 'core'), scope ? `${scope}/core` : 'core', 'core');
353
+ await put(w, join('src', 'index.ts'), sourceModule(s.name));
354
+ await put(w, join('src', 'index.test.ts'), smokeTest(s.name));
355
+ await writePackage(w, join('packages', 'core'), s.scope ? `${s.scope}/core` : 'core', 'core');
356
+ }
357
+ }
358
+ /**
359
+ * Plan the scaffold on a dry writer and return the paths that already exist on
360
+ * disk — the files `init` would clobber. The Stop hook is excluded: it merges
361
+ * into `.claude/settings.json` idempotently rather than overwriting.
362
+ */
363
+ async function planCollisions(s, cwd) {
364
+ const plan = { cwd, dryRun: true, written: [] };
365
+ await writeNewScaffold(plan, s);
366
+ return plan.written.filter((rel) => existsSync(join(cwd, rel)));
367
+ }
368
+ /** Resolve the project name, scope, and derived scoped `fullName` for a new project. */
369
+ function resolveProjectName(options, cwd) {
370
+ const name = options.name ?? (basename(cwd) || 'app');
371
+ const scope = options.scope ?? null;
372
+ return { name, scope, fullName: scope ? `${scope}/${name}` : name };
373
+ }
374
+ /** Resolve the options for a new-project scaffold, applying every default. */
375
+ function resolveNewScaffold(options, cwd) {
376
+ const { name, scope, fullName } = resolveProjectName(options, cwd);
377
+ return {
378
+ shape: options.shape ?? 'flat',
379
+ name,
380
+ scope,
381
+ license: options.license ?? 'MIT',
382
+ author: options.author ?? null,
383
+ fullName,
384
+ adapters: options.adapters ?? ADAPTERS,
385
+ slots: options.slots ?? SLOTS,
386
+ // Exact pin, no caret: pre-1.0 minors are breaking (docs/contract.md pin policy).
387
+ checkrideSpec: options.checkrideSpec ?? productVersion(),
388
+ };
389
+ }
390
+ /**
391
+ * Overwrite protection (D4): unless `force`, refuse rather than clobber any file
392
+ * the scaffold would write. Planning on a dry writer first means a collision
393
+ * bails before a single real byte is written — the refusal writes nothing.
394
+ */
395
+ async function assertNoCollisions(scaffold, cwd, force) {
396
+ if (force)
397
+ return;
398
+ const collisions = await planCollisions(scaffold, cwd);
399
+ if (collisions.length > 0) {
400
+ throw new Error(`init: refusing to overwrite ${collisions.length} existing file(s) (pass --force to overwrite):\n` +
401
+ collisions.map((c) => ` ${c}`).join('\n'));
363
402
  }
403
+ }
404
+ /** Print the new-project summary and next steps. */
405
+ function reportNew(stdout, shape, w, cwd) {
406
+ stdout.write(`checkride init: generated a ${shape} project (${w.written.length} files)${w.dryRun ? ' [dry run]' : ''}.\n`);
407
+ // A fresh project has no lockfile/field yet, so this resolves to `pnpm`,
408
+ // matching the generated scripts.
409
+ const pm = detectPackageManager({ cwd });
410
+ stdout.write(` next: ${pm} install && ${pm} run check\n`);
411
+ }
412
+ async function initNew(options, cwd) {
413
+ const scaffold = resolveNewScaffold(options, cwd);
414
+ await assertNoCollisions(scaffold, cwd, options.force ?? false);
415
+ const w = { cwd, dryRun: options.dryRun ?? false, written: [] };
416
+ await writeNewScaffold(w, scaffold);
364
417
  // Claude Code Stop hook (opt-out; step 12). A fresh project has no PM lockfile
365
418
  // yet, so it resolves to the `pnpm` default — matching the generated scripts.
366
419
  await writeHook(w, options.hook);
367
- if (options.stdout) {
368
- options.stdout.write(`checkride init: generated a ${shape} project (${w.written.length} files)${w.dryRun ? ' [dry run]' : ''}.\n`);
369
- }
370
- return { mode: 'new', shape, written: w.written, skipped: [], disabled: [], grandfathered: [], exitCode: 0 };
420
+ if (options.stdout)
421
+ reportNew(options.stdout, scaffold.shape, w, cwd);
422
+ return { mode: 'new', shape: scaffold.shape, written: w.written, skipped: [], disabled: [], grandfathered: [], exitCode: 0 };
371
423
  }
372
424
  // ----------------------------------------------------------------------------
373
425
  // Existing-project mode
@@ -380,6 +432,133 @@ async function readIfExists(path) {
380
432
  return null;
381
433
  }
382
434
  }
435
+ // ----------------------------------------------------------------------------
436
+ // Publish-ready bundle (step 9): the library path scaffolds the opt-in artifact
437
+ // checks into `checkride.config.json` so a buildable library orders itself out
438
+ // of the box (build → pack/smoke/attw/publint/snippets, D4).
439
+ // ----------------------------------------------------------------------------
440
+ /**
441
+ * The publish-ready bundle, in wave order. Each slot's value is the adapter
442
+ * `init` writes into `checks`; `build` and `snippets` are gated (see
443
+ * {@link planPublishBundle}). publint/attw/pack/smoke each resolve to the
444
+ * same-named adapter, so their config value is just the slot name.
445
+ */
446
+ const PUBLISH_BUNDLE_SLOTS = ['build', 'publint', 'attw', 'pack', 'smoke', 'snippets'];
447
+ /** `--add` values that expand to the whole bundle. */
448
+ const PUBLISH_BUNDLE_ALIASES = new Set(['publish', 'bundle']);
449
+ /** Printed (and recorded in `skipped`) when snippets is requested but no tagged fence exists (Q12). */
450
+ const SNIPPETS_POINTER = 'snippets-dist: no tagged fences found — add a `<!-- snippet: check -->` marker above a ' +
451
+ 'ts/typescript fence in README.md or docs/*.md to enable the doc-snippet typecheck';
452
+ /**
453
+ * Read package.json for the library path: its script names, and whether it is a
454
+ * publishable library (declares an `exports`/`main` entry and is not private).
455
+ */
456
+ function readInitManifest(cwd) {
457
+ try {
458
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
459
+ const hasEntry = pkg.exports !== undefined || (typeof pkg.main === 'string' && pkg.main.length > 0);
460
+ return { scripts: new Set(Object.keys(pkg.scripts ?? {})), isLibrary: hasEntry && pkg.private !== true };
461
+ }
462
+ catch {
463
+ return { scripts: new Set(), isLibrary: false };
464
+ }
465
+ }
466
+ /**
467
+ * True when README.md or a non-recursive docs/*.md carries at least one tagged
468
+ * snippet fence — the signal that gates scaffolding `snippets-dist` (Q12).
469
+ * Reuses step 7's pure discovery/plan primitives so the detection matches what
470
+ * the slot itself checks.
471
+ */
472
+ async function hasTaggedSnippets(cwd) {
473
+ let docsEntries = [];
474
+ try {
475
+ docsEntries = await readdir(join(cwd, 'docs'));
476
+ }
477
+ catch {
478
+ // No docs/ directory — README.md alone can still carry a tagged fence.
479
+ }
480
+ const docs = [];
481
+ for (const relPath of selectDocFiles(docsEntries)) {
482
+ // oxlint-disable-next-line no-await-in-loop -- a handful of docs; sequential keeps it simple.
483
+ const text = await readIfExists(join(cwd, relPath));
484
+ if (text !== null)
485
+ docs.push({ relPath, text });
486
+ }
487
+ return planSnippets(docs).checked > 0;
488
+ }
489
+ /**
490
+ * Fold the requested publish slots into config entries, gating `build` on a
491
+ * build script and `snippets` (→ `snippets-dist`) on a tagged fence (Q12) — the
492
+ * two slots that would otherwise stand down as a skip (`build`) or hard-error on
493
+ * zero snippets (`snippets`).
494
+ */
495
+ function planPublishBundle(requested, hasBuildScript, taggedSnippets) {
496
+ const checks = {};
497
+ let snippetsPointer = null;
498
+ for (const slot of PUBLISH_BUNDLE_SLOTS) {
499
+ if (!requested.has(slot))
500
+ continue;
501
+ if (slot === 'build') {
502
+ if (hasBuildScript)
503
+ checks['build'] = 'build';
504
+ }
505
+ else if (slot === 'snippets') {
506
+ if (taggedSnippets)
507
+ checks['snippets'] = 'snippets-dist';
508
+ else
509
+ snippetsPointer = SNIPPETS_POINTER;
510
+ }
511
+ else {
512
+ checks[slot] = slot; // publint / attw / pack / smoke — adapter name == slot name.
513
+ }
514
+ }
515
+ return { checks, snippetsPointer };
516
+ }
517
+ /**
518
+ * Partition the `--add` list and the library signal into (a) the publish slots
519
+ * to scaffold into `checks` and (b) the file-scaffold `--add` names left for
520
+ * {@link addConfigs}. The library path — a buildable, publishable package —
521
+ * requests the whole bundle; `--add` requests a named publish slot, or the
522
+ * whole bundle via the `publish`/`bundle` alias.
523
+ */
524
+ function splitAdd(add, manifest) {
525
+ const publishRequested = new Set();
526
+ const fileAdds = [];
527
+ if (manifest.isLibrary && manifest.scripts.has('build')) {
528
+ for (const slot of PUBLISH_BUNDLE_SLOTS)
529
+ publishRequested.add(slot);
530
+ }
531
+ const bundleNames = new Set(PUBLISH_BUNDLE_SLOTS);
532
+ for (const name of add) {
533
+ if (PUBLISH_BUNDLE_ALIASES.has(name))
534
+ for (const slot of PUBLISH_BUNDLE_SLOTS)
535
+ publishRequested.add(slot);
536
+ else if (bundleNames.has(name))
537
+ publishRequested.add(name);
538
+ else
539
+ fileAdds.push(name);
540
+ }
541
+ return { publishRequested, fileAdds };
542
+ }
543
+ /**
544
+ * Write (or refresh) the AGENTS.md stanza for `slots`, idempotently: it writes
545
+ * only when the applied stanza differs from the current file, records the outcome
546
+ * on `w`/`skipped`, and honours dry-run. Shared by `initExisting` and
547
+ * `runAgentSetup` (both create-or-refresh the same stanza).
548
+ */
549
+ async function writeAgentsStanza(w, cwd, slots, skipped) {
550
+ const agentsPath = join(cwd, 'AGENTS.md');
551
+ const existing = await readIfExists(agentsPath);
552
+ const nextAgents = applyStanza(existing ?? '', buildStanza(slots));
553
+ if (nextAgents !== existing) {
554
+ if (!w.dryRun)
555
+ await writeFile(agentsPath, nextAgents);
556
+ w.written.push(existing === null ? 'AGENTS.md' : 'AGENTS.md (refreshed stanza)');
557
+ }
558
+ else {
559
+ skipped.push('AGENTS.md (stanza unchanged)');
560
+ }
561
+ }
383
562
  // Blessed-default config files `--add <slot>` scaffolds for an empty slot.
384
563
  // Shape-specific slots (types, dead) use the flat variant — existing repos
385
564
  // adopting checkride incrementally are overwhelmingly single-package.
@@ -395,6 +574,9 @@ const ADD_CONFIGS = {
395
574
  ['shared/rules/require-js-extension.yml', 'rules/require-js-extension.yml'],
396
575
  ],
397
576
  dead: [['flat/fallow.toml', 'fallow.toml']],
577
+ // dupes/health are the same fallow tool + config as `dead`, just other slots.
578
+ dupes: [['flat/fallow.toml', 'fallow.toml']],
579
+ health: [['flat/fallow.toml', 'fallow.toml']],
398
580
  test: [['shared/vitest.config.ts.template', 'vitest.config.ts']],
399
581
  docs: [['shared/markdownlint-cli2.jsonc', '.markdownlint-cli2.jsonc']],
400
582
  spell: [['shared/cspell.json', 'cspell.json']],
@@ -410,6 +592,7 @@ async function addConfigs(w, add, skipped) {
410
592
  for (const [from, to] of files) {
411
593
  if (existsSync(join(w.cwd, to)))
412
594
  skipped.push(`${to} (exists)`);
595
+ // oxlint-disable-next-line no-await-in-loop -- one-shot `--add` scaffolding: writes are independent and perf-irrelevant, kept sequential for a deterministic progress list.
413
596
  else
414
597
  await put(w, to, readTemplate(from));
415
598
  }
@@ -421,7 +604,17 @@ async function addCheckAlias(w, skipped) {
421
604
  const raw = await readIfExists(pkgPath);
422
605
  if (raw === null)
423
606
  return;
424
- const pkg = JSON.parse(raw);
607
+ let pkg;
608
+ try {
609
+ pkg = JSON.parse(raw);
610
+ }
611
+ catch (err) {
612
+ // Name the file so a malformed consumer package.json reads as
613
+ // `invalid package.json: <reason>`, not a bare SyntaxError stack
614
+ // (mirrors `invalidConfig` in `config.ts`).
615
+ const reason = err instanceof Error ? err.message : String(err);
616
+ throw new Error(`invalid package.json: ${reason}`, { cause: err });
617
+ }
425
618
  if (pkg.scripts?.['check']) {
426
619
  skipped.push('package.json (check script exists)');
427
620
  return;
@@ -431,55 +624,57 @@ async function addCheckAlias(w, skipped) {
431
624
  await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
432
625
  w.written.push('package.json (added check script)');
433
626
  }
434
- async function initExisting(options, cwd) {
435
- const adapters = options.adapters ?? ADAPTERS;
436
- const slots = options.slots ?? SLOTS;
437
- const w = { cwd, dryRun: options.dryRun ?? false, written: [] };
438
- const skipped = [];
439
- // --add scaffolds blessed configs for empty slots before inventory, so the
440
- // additions are detected and adopted in this same run.
441
- await addConfigs(w, options.add ?? [], skipped);
442
- const items = inventory({ cwd, slots, adapters });
443
- const adopted = items.filter((i) => i.status === 'adopted');
444
- // Run each adopted check once to see what currently fails.
445
- const probe = options.probeFailures ?? defaultProbe;
446
- const failing = new Set(await probe(adopted.map((i) => i.slot), cwd));
447
- // Adoption path for the failures. `--baseline` (step 6 / c10) grandfathers a
448
- // failing *fingerprintable* slot into checkride.baseline.json and keeps it
449
- // enabled — the baseline masks its current debt on the next run, so it's the
450
- // ratchet, not a disabled slot, that carries the cleanup. A failing slot with
451
- // no extractor can't be grandfathered, so it still falls back to a `false`
452
- // disable. Without `--baseline`, every failing slot is disabled (the original
453
- // step-3 behavior): the first `pnpm check` is green-ish, re-enable as you fix.
454
- const useBaseline = options.baseline ?? false;
627
+ /**
628
+ * Classify each failing adopted slot. `--baseline` (step 6 / c10) grandfathers a
629
+ * failing *fingerprintable* slot (kept enabled; the baseline masks its debt and
630
+ * the ratchet carries the cleanup); a failing slot with no extractor can't be
631
+ * grandfathered, so it still falls back to a `false` disable. Without
632
+ * `--baseline`, every failing slot is disabled (re-enable as you fix).
633
+ */
634
+ function resolveAdoptionPlan(adopted, failing, useBaseline) {
455
635
  const grandfathered = useBaseline
456
- ? adopted.filter((i) => failing.has(i.slot) && i.adapter !== null && isFingerprintable(i.adapter)).map((i) => i.slot)
636
+ ? adopted
637
+ .filter((i) => failing.has(i.slot) && i.adapter !== null && isFingerprintable(i.adapter))
638
+ .map((i) => i.slot)
457
639
  : [];
458
640
  const grandfatheredSet = new Set(grandfathered);
459
- if (grandfathered.length > 0) {
460
- const capture = options.captureBaseline ?? ((at) => runBaseline({ cwd: at }).then(() => undefined));
461
- await capture(cwd);
462
- }
463
- const disabled = adopted.filter((i) => failing.has(i.slot) && !grandfatheredSet.has(i.slot)).map((i) => i.slot);
464
- const disabledSet = new Set(disabled);
465
- // checkride.config.json reflecting adopted tools (and disabled failures). Never
466
- // overwrite an existing one.
467
- const configPath = join(cwd, 'checkride.config.json');
468
- if (!existsSync(configPath)) {
469
- const checks = {};
470
- for (const i of adopted) {
471
- if (disabledSet.has(i.slot))
472
- checks[i.slot] = false;
473
- else if (i.adapter)
474
- checks[i.slot] = i.adapter;
475
- }
476
- const config = { $schema: configSchemaUrl(productVersion()), checks };
477
- await put(w, 'checkride.config.json', `${JSON.stringify(config, null, 2)}\n`);
478
- }
479
- else {
641
+ const disabled = adopted
642
+ .filter((i) => failing.has(i.slot) && !grandfatheredSet.has(i.slot))
643
+ .map((i) => i.slot);
644
+ return { grandfathered, disabled };
645
+ }
646
+ /** Capture a fresh baseline when any slot was grandfathered (a no-op on dry-run). */
647
+ async function captureBaselineIfNeeded(options, cwd, grandfathered, dryRun) {
648
+ if (grandfathered.length === 0 || dryRun)
649
+ return;
650
+ const capture = options.captureBaseline ?? ((at) => runBaseline({ cwd: at }).then(() => undefined));
651
+ await capture(cwd);
652
+ }
653
+ /**
654
+ * Write checkride.config.json for the adopted tools (disabled failures as
655
+ * `false`) plus the publish-ready bundle (step 9); never clobber an existing one.
656
+ */
657
+ async function writeExistingConfig(w, cwd, adopted, disabledSet, bundleChecks, skipped) {
658
+ if (existsSync(join(cwd, 'checkride.config.json'))) {
480
659
  skipped.push('checkride.config.json (exists)');
660
+ return;
481
661
  }
482
- // .gitignore: ensure .check/ is ignored (append, never clobber).
662
+ const checks = {};
663
+ for (const i of adopted) {
664
+ if (disabledSet.has(i.slot))
665
+ checks[i.slot] = false;
666
+ else if (i.adapter)
667
+ checks[i.slot] = i.adapter;
668
+ }
669
+ // The opt-in publish bundle follows the adopted default slots; explicit
670
+ // entries opt these slots into the run (D4 — the library orders itself).
671
+ for (const [slot, adapter] of Object.entries(bundleChecks))
672
+ checks[slot] = adapter;
673
+ const config = { $schema: configSchemaUrl(productVersion()), checks };
674
+ await put(w, 'checkride.config.json', `${JSON.stringify(config, null, 2)}\n`);
675
+ }
676
+ /** Ensure `.check/` is gitignored: create the file, append to it, or note it's already there. */
677
+ async function ensureGitignore(w, cwd, skipped) {
483
678
  const gitignorePath = join(cwd, '.gitignore');
484
679
  const gi = await readIfExists(gitignorePath);
485
680
  if (gi === null) {
@@ -493,39 +688,80 @@ async function initExisting(options, cwd) {
493
688
  else {
494
689
  skipped.push('.gitignore (.check/ already ignored)');
495
690
  }
496
- // package.json: add the `check: checkride` alias (decision 8) if missing.
497
- await addCheckAlias(w, skipped);
498
- // AGENTS.md stanza (create or refresh, idempotent).
499
- const agentsPath = join(cwd, 'AGENTS.md');
500
- const existing = await readIfExists(agentsPath);
501
- const stanza = buildStanza(adopted.map((i) => i.slot));
502
- const nextAgents = applyStanza(existing ?? '', stanza);
503
- if (nextAgents !== existing) {
504
- if (!w.dryRun)
505
- await writeFile(agentsPath, nextAgents);
506
- w.written.push(existing === null ? 'AGENTS.md' : 'AGENTS.md (refreshed stanza)');
507
- }
508
- else {
509
- skipped.push('AGENTS.md (stanza unchanged)');
691
+ }
692
+ /** Write the CLAUDE.md pointer if absent. */
693
+ async function writeClaudePointer(w, cwd, skipped) {
694
+ if (existsSync(join(cwd, 'CLAUDE.md'))) {
695
+ skipped.push('CLAUDE.md (exists)');
696
+ return;
510
697
  }
511
- // CLAUDE.md pointer if absent.
512
- const claudePath = join(cwd, 'CLAUDE.md');
513
- if (!existsSync(claudePath)) {
514
- await put(w, 'CLAUDE.md', claudeMd());
698
+ await put(w, 'CLAUDE.md', claudeMd());
699
+ }
700
+ /** Print the existing-repo adoption summary (adopted count + grandfathered/disabled slots). */
701
+ function reportExisting(stdout, adopted, w, grandfathered, disabled) {
702
+ stdout.write(`checkride init: adopted ${adopted.length} slot(s); wrote ${w.written.length} file(s)${w.dryRun ? ' [dry run]' : ''}.\n`);
703
+ if (grandfathered.length > 0) {
704
+ stdout.write(` grandfathered failing slots into ${BASELINE_FILE}: ${grandfathered.join(', ')}\n`);
515
705
  }
516
- else {
517
- skipped.push('CLAUDE.md (exists)');
706
+ if (disabled.length > 0) {
707
+ stdout.write(` disabled failing slots (enable as you fix): ${disabled.join(', ')}\n`);
518
708
  }
709
+ }
710
+ /**
711
+ * Plan the publish-ready bundle (step 9) for existing-repo init. Only meaningful
712
+ * when a config will actually be written: `writeExistingConfig` never clobbers an
713
+ * existing `checkride.config.json`, so an existing config means the bundle has
714
+ * nowhere to land. The bundle is scaffolded *enabled* — not probed/disabled like
715
+ * adopted tools — because it is an ordered pipeline (build → artifacts) the user
716
+ * opts into by naming it.
717
+ */
718
+ async function planInitBundle(publishRequested, manifest, cwd) {
719
+ if (existsSync(join(cwd, 'checkride.config.json')))
720
+ return { checks: {}, snippetsPointer: null };
721
+ const wantsSnippets = publishRequested.has('snippets');
722
+ return planPublishBundle(publishRequested, manifest.scripts.has('build'), wantsSnippets ? await hasTaggedSnippets(cwd) : false);
723
+ }
724
+ /** Append the enabled publish-bundle slots (and any snippets pointer) to the summary. */
725
+ function reportInitBundle(stdout, bundle) {
726
+ const enabled = Object.keys(bundle.checks);
727
+ if (enabled.length > 0)
728
+ stdout.write(` enabled publish bundle: ${enabled.join(', ')}\n`);
729
+ if (bundle.snippetsPointer)
730
+ stdout.write(` ${bundle.snippetsPointer}\n`);
731
+ }
732
+ async function initExisting(options, cwd) {
733
+ const adapters = options.adapters ?? ADAPTERS;
734
+ const slots = options.slots ?? SLOTS;
735
+ const manifest = readInitManifest(cwd);
736
+ const w = { cwd, dryRun: options.dryRun ?? false, written: [] };
737
+ const skipped = [];
738
+ // Route --add: publish slots (and the library path) opt into the bundle; the
739
+ // rest scaffold blessed configs before inventory, so they're detected this run.
740
+ const { publishRequested, fileAdds } = splitAdd(options.add ?? [], manifest);
741
+ await addConfigs(w, fileAdds, skipped);
742
+ const items = inventory({ cwd, slots, adapters });
743
+ const adopted = items.filter((i) => i.status === 'adopted');
744
+ // Run each adopted check once to see what currently fails.
745
+ const probe = options.probeFailures ?? defaultProbe;
746
+ const failing = new Set(await probe(adopted.map((i) => i.slot), cwd));
747
+ const { grandfathered, disabled } = resolveAdoptionPlan(adopted, failing, options.baseline ?? false);
748
+ await captureBaselineIfNeeded(options, cwd, grandfathered, w.dryRun);
749
+ // Publish-ready bundle (step 9): planned only when a config will be written.
750
+ const bundle = await planInitBundle(publishRequested, manifest, cwd);
751
+ if (bundle.snippetsPointer)
752
+ skipped.push(bundle.snippetsPointer);
753
+ await writeExistingConfig(w, cwd, adopted, new Set(disabled), bundle.checks, skipped);
754
+ await ensureGitignore(w, cwd, skipped);
755
+ // package.json: add the `check: checkride` alias (decision 8) if missing.
756
+ await addCheckAlias(w, skipped);
757
+ // AGENTS.md stanza (create or refresh, idempotent).
758
+ await writeAgentsStanza(w, cwd, adopted.map((i) => i.slot), skipped);
759
+ await writeClaudePointer(w, cwd, skipped);
519
760
  // Claude Code Stop hook (opt-out; step 12), using the repo's detected PM (b7).
520
761
  await writeHook(w, options.hook, skipped);
521
762
  if (options.stdout) {
522
- options.stdout.write(`checkride init: adopted ${adopted.length} slot(s); wrote ${w.written.length} file(s)${w.dryRun ? ' [dry run]' : ''}.\n`);
523
- if (grandfathered.length > 0) {
524
- options.stdout.write(` grandfathered failing slots into ${BASELINE_FILE}: ${grandfathered.join(', ')}\n`);
525
- }
526
- if (disabled.length > 0) {
527
- options.stdout.write(` disabled failing slots (enable as you fix): ${disabled.join(', ')}\n`);
528
- }
763
+ reportExisting(options.stdout, adopted, w, grandfathered, disabled);
764
+ reportInitBundle(options.stdout, bundle);
529
765
  }
530
766
  return { mode: 'existing', shape: null, written: w.written, skipped, disabled, grandfathered, exitCode: 0 };
531
767
  }
@@ -558,17 +794,7 @@ export async function runAgentSetup(options) {
558
794
  await addCheckAlias(w, skipped);
559
795
  // AGENTS.md stanza for the currently-adopted slots (create or refresh).
560
796
  const adopted = inventory({ cwd, slots, adapters }).filter((i) => i.status === 'adopted');
561
- const agentsPath = join(cwd, 'AGENTS.md');
562
- const existing = await readIfExists(agentsPath);
563
- const nextAgents = applyStanza(existing ?? '', buildStanza(adopted.map((i) => i.slot)));
564
- if (nextAgents !== existing) {
565
- if (!w.dryRun)
566
- await writeFile(agentsPath, nextAgents);
567
- w.written.push(existing === null ? 'AGENTS.md' : 'AGENTS.md (refreshed stanza)');
568
- }
569
- else {
570
- skipped.push('AGENTS.md (stanza unchanged)');
571
- }
797
+ await writeAgentsStanza(w, cwd, adopted.map((i) => i.slot), skipped);
572
798
  await writeHook(w, options.hook, skipped);
573
799
  if (options.stdout) {
574
800
  options.stdout.write(`checkride agent-setup: wrote ${w.written.length} file(s)${w.dryRun ? ' [dry run]' : ''}.\n`);