nitm-ai-dev-toolkit 5.1.2 → 5.1.3

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/bin/cli.js CHANGED
@@ -13,6 +13,7 @@ const { execFileSync, spawnSync } = require('child_process');
13
13
  const factory = require('./factory-watch.js');
14
14
  const factoryArchive = require('./factory-archive.js');
15
15
  const factoryPrune = require('./factory-prune.js');
16
+ const relevance = require('./relevance.js');
16
17
 
17
18
  const PKG_ROOT = path.join(__dirname, '..');
18
19
  const SRC_DIR = path.join(PKG_ROOT, 'src');
@@ -118,6 +119,10 @@ const KNOWN_HISTORICAL = {
118
119
  // First release with --dry-run, --exclude and --only. `upgrade` refuses a dry run below it (older
119
120
  // releases would write), and refuses a selection below it (older releases would ignore it).
120
121
  const DRY_RUN_UPGRADE_MIN = '5.0.1';
122
+ // First release whose patch and install honour .nitm/retired.json and the relevance plan. Upgrade refuses
123
+ // older targets while that file has entries or a tracker override, because those targets would ignore it.
124
+ // Set to the version that ships this change; keep in step with the CHANGELOG [Unreleased] release.
125
+ const RELEVANCE_MIN = '5.2.0';
121
126
  const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
122
127
 
123
128
  const C = {
@@ -306,30 +311,66 @@ function blockerOf(cwd, dest) {
306
311
  return null;
307
312
  }
308
313
 
309
- // One entry per template file the command touches.
310
- // add: missing in the target
311
- // overwrite: install --force on an existing file
312
- // skip: patch keeps an existing file (identical or user-changed)
313
- // conflict: install without --force on an existing file, or a path blocked by a file/dir of another type
314
- function planFiles(type, cwd, { mode, force, env, selection }) {
314
+ // One entry per template file the command touches. `action`:
315
+ // add: missing in the target and applies to this project (patch)
316
+ // update: patch refreshes a file the toolkit wrote and the user never changed (manifest hash matches)
317
+ // overwrite: install --force on an existing file
318
+ // skip: identical to the template, already current
319
+ // review: locally changed, or missing while relevance is undecided; kept and reported, never overwritten
320
+ // irrelevant: does not apply to this project (missing: not added; present: kept, reported)
321
+ // retired: listed in .nitm/retired.json; never added, updated or overwritten
322
+ // conflict: install without --force on an existing file, or a path blocked by a file/dir of another type
323
+ // `retired` and `facts` may be passed in; otherwise they are read from the project.
324
+ function planFiles(type, cwd, { mode, force, env, selection, retired, facts }) {
325
+ const retiredConfig = retired || relevance.readRetired(cwd);
326
+ const projectFacts = mode === 'patch' ? (facts || relevance.detectProject(cwd, retiredConfig)) : null;
327
+ const manifest = mode === 'patch' ? readManifest(cwd).files : {};
315
328
  const rels = scopeFiles(templateFiles(type), env).filter((rel) => selectionKeeps(rel, selection));
316
329
  return rels.map((rel) => {
317
330
  const dest = installName(rel);
318
331
  const from = path.join(SRC_DIR, type, rel);
319
332
  const hash = fileHash(from);
333
+ const key = dest.split(path.sep).join('/');
334
+ const retirement = relevance.retiredFor(key, retiredConfig.entries);
335
+ if (retirement) {
336
+ return { rel: dest, from, hash, action: 'retired', reason: `${retirement.reason} (retired ${retirement.date})` };
337
+ }
320
338
  const blocker = blockerOf(cwd, dest);
321
339
  if (blocker) return { rel: dest, from, hash, action: 'conflict', reason: `blocked by ${blocker}` };
322
340
  const to = path.join(cwd, dest);
323
- if (!fs.existsSync(to)) return { rel: dest, from, hash, action: 'add', same: false };
324
- const same = fileHash(to) === hash;
325
- if (mode === 'install' && !force) return { rel: dest, from, hash, action: 'conflict', reason: 'exists', same };
326
- if (mode === 'install') return { rel: dest, from, hash, action: 'overwrite', same };
327
- return { rel: dest, from, hash, action: 'skip', same, reason: same ? 'identical' : 'kept, differs from template' };
341
+ if (mode === 'install') {
342
+ if (!fs.existsSync(to)) return { rel: dest, from, hash, action: 'add', same: false };
343
+ const same = fileHash(to) === hash;
344
+ if (!force) return { rel: dest, from, hash, action: 'conflict', reason: 'exists', same };
345
+ return { rel: dest, from, hash, action: 'overwrite', same };
346
+ }
347
+ return patchEntry({ dest, from, hash, to, key, facts: projectFacts, manifest });
328
348
  });
329
349
  }
330
350
 
351
+ // Patch decision for one template file that is not retired and not blocked.
352
+ function patchEntry({ dest, from, hash, to, key, facts, manifest }) {
353
+ const relevant = relevance.relevanceOf(key, facts);
354
+ if (!fs.existsSync(to)) {
355
+ if (relevant.status === 'add') return { rel: dest, from, hash, action: 'add', same: false };
356
+ return { rel: dest, from, hash, action: relevant.status === 'review' ? 'review' : 'irrelevant', reason: relevant.reason };
357
+ }
358
+ const local = fileHash(to);
359
+ const same = local === hash;
360
+ if (relevant.status === 'irrelevant') {
361
+ return { rel: dest, from, hash, action: 'irrelevant', same, present: true, reason: relevant.reason };
362
+ }
363
+ if (same) return { rel: dest, from, hash, action: 'skip', same: true, reason: 'identical' };
364
+ if (relevant.status === 'add' && manifest[key] === local) {
365
+ return { rel: dest, from, hash, action: 'update', same: false, reason: 'unchanged since the toolkit wrote it' };
366
+ }
367
+ return { rel: dest, from, hash, action: 'review', same: false, reason: 'kept, differs from template' };
368
+ }
369
+
370
+ const PLAN_ACTIONS = ['add', 'update', 'overwrite', 'skip', 'review', 'irrelevant', 'retired', 'conflict'];
371
+
331
372
  function countActions(plan) {
332
- const counts = { add: 0, overwrite: 0, skip: 0, conflict: 0 };
373
+ const counts = Object.fromEntries(PLAN_ACTIONS.map((a) => [a, 0]));
333
374
  for (const e of plan) counts[e.action]++;
334
375
  return counts;
335
376
  }
@@ -337,7 +378,7 @@ function countActions(plan) {
337
378
  function applyPlan(cwd, plan) {
338
379
  let copied = 0;
339
380
  for (const e of plan) {
340
- if (e.action !== 'add' && e.action !== 'overwrite') continue;
381
+ if (!['add', 'update', 'overwrite'].includes(e.action)) continue;
341
382
  const to = path.join(cwd, e.rel);
342
383
  try {
343
384
  fs.mkdirSync(path.dirname(to), { recursive: true });
@@ -361,19 +402,46 @@ function conflicts(type, cwd, env) {
361
402
  .map((e) => e.rel);
362
403
  }
363
404
 
364
- function printPlan(plan) {
405
+ const PLAN_SECTIONS = [
406
+ ['add', 'Would add'],
407
+ ['update', 'Would update (unchanged since the toolkit wrote them; template changed)'],
408
+ ['overwrite', 'Would overwrite'],
409
+ ['review', 'Needs review (not written)'],
410
+ ['irrelevant', 'Not relevant to this project (not written)'],
411
+ ['retired', 'Retired (never re-added)'],
412
+ ['conflict', 'Conflicts'],
413
+ ];
414
+
415
+ // The plan is shown before any write: what is added, updated, held back and why.
416
+ function printPlan(plan, dryRun) {
365
417
  const c = countActions(plan);
366
- info(`Dry run, nothing will be written: ${c.add} add, ${c.overwrite} overwrite, ${c.skip} skip, ${c.conflict} conflict.`);
367
- const sections = [['add', 'Would add'], ['overwrite', 'Would overwrite'],
368
- ['skip', 'Would skip (existing, kept)'], ['conflict', 'Conflicts']];
369
- for (const [action, label] of sections) {
370
- const list = plan.filter((e) => e.action === action);
418
+ const summary = `${c.add} add, ${c.update} update, ${c.overwrite} overwrite, ${c.review} need review, `
419
+ + `${c.irrelevant} not relevant, ${c.retired} retired, ${c.skip} already current, ${c.conflict} conflict.`;
420
+ info(dryRun ? `Dry run, nothing will be written: ${summary}` : `Plan: ${summary}`);
421
+ for (const [action, label] of PLAN_SECTIONS) {
422
+ // Present files that do not apply are kept silently here; doctor lists them as retirement candidates.
423
+ const list = plan.filter((e) => e.action === action && !(action === 'irrelevant' && e.present));
371
424
  if (!list.length) continue;
372
425
  console.log(` ${label} (${list.length}):`);
373
426
  for (const e of list) console.log(` - ${e.rel}${e.reason ? ` (${e.reason})` : ''}`);
374
427
  }
375
428
  }
376
429
 
430
+ function printFacts(facts) {
431
+ const yn = (v) => (v ? 'yes' : 'no');
432
+ const tracker = facts.trackers === null
433
+ ? `not detected (set "trackers" in ${relevance.RETIRED_FILE})`
434
+ : `${facts.trackers.join(', ') || 'none'} (${facts.trackerSource})`;
435
+ info(`Project facts: tracker ${tracker}; Playwright ${yn(facts.playwright)}; Prisma ${yn(facts.prisma)}; UI ${yn(facts.ui)}.`);
436
+ }
437
+
438
+ // Harness wrappers that inline policy or diverge from their snippet. Reported only; nothing is moved.
439
+ function printWrappers(wrappers) {
440
+ if (!wrappers.length) return;
441
+ warn(`${wrappers.length} harness wrapper(s) inline policy or diverge from their snippet. Propose moving the content into the snippet and keeping the wrapper a thin link (nothing changed):`);
442
+ wrappers.forEach((w) => console.log(` - ${w.rel} (${w.status}: ${w.reason})`));
443
+ }
444
+
377
445
  // ---- Handoff, version, manifest, selection (the files written besides templates) ----
378
446
 
379
447
  function envHeader(type, env) {
@@ -498,7 +566,7 @@ function recordState(cwd, { type, env, plan, selection, explicit, dryRun }) {
498
566
  const version = writeState(path.join(cwd, VERSION_FILE), versionText);
499
567
  const files = { ...readManifest(cwd).files };
500
568
  for (const e of plan) {
501
- if (e.action === 'add' || e.action === 'overwrite' || (e.action === 'skip' && e.same)) {
569
+ if (['add', 'update', 'overwrite'].includes(e.action) || (e.action === 'skip' && e.same)) {
502
570
  files[e.rel.split(path.sep).join('/')] = e.hash;
503
571
  }
504
572
  }
@@ -562,7 +630,7 @@ function cmdInstall(cwd, flags) {
562
630
  const blocked = cf.filter((e) => e.reason !== 'exists');
563
631
 
564
632
  if (flags.dryRun) {
565
- printPlan(plan);
633
+ printPlan(plan, true);
566
634
  printDryState(recordState(cwd, { type, env: flags.env, plan, selection, explicit, dryRun: true }));
567
635
  if (cf.length) process.exitCode = 1;
568
636
  return { copied: 0, skipped: 0, conflicts: cf.length, dryRun: true, planned: counts };
@@ -587,32 +655,41 @@ function cmdInstall(cwd, flags) {
587
655
  return { copied, skipped: counts.skip, conflicts: 0, dryRun: false, planned: counts };
588
656
  }
589
657
 
658
+ // Classifies every template file against the project, shows the plan, then writes only add and update
659
+ // entries. Existing files that differ are reported, never overwritten (not even with --force).
590
660
  function cmdPatch(cwd, flags) {
591
661
  const type = detectType(cwd, flags);
592
662
  const { selection, explicit } = resolveSelection(cwd, flags);
593
- // patch never overwrites existing files, even with --force (data-loss guard)
594
- const plan = planFiles(type, cwd, { mode: 'patch', env: flags.env, selection });
663
+ const retired = relevance.readRetired(cwd);
664
+ const facts = relevance.detectProject(cwd, retired);
665
+ const plan = planFiles(type, cwd, { mode: 'patch', env: flags.env, selection, retired, facts });
595
666
  const counts = countActions(plan);
596
667
  const conflictEntries = plan.filter((e) => e.action === 'conflict');
597
668
  const stale = findStale(cwd, type);
669
+ const wrappers = relevance.auditWrappers(cwd, plan);
598
670
 
599
671
  if (flags.dryRun) {
600
- printPlan(plan);
672
+ printFacts(facts);
673
+ printPlan(plan, true);
674
+ printWrappers(wrappers);
601
675
  printStale(stale);
602
676
  printDryState(recordState(cwd, { type, env: flags.env, plan, selection, explicit, dryRun: true }));
603
677
  if (conflictEntries.length) process.exitCode = 1;
604
- return { copied: 0, skipped: counts.skip, conflicts: conflictEntries.length, stale, dryRun: true, planned: counts };
678
+ return { copied: 0, skipped: counts.skip, conflicts: conflictEntries.length, stale, wrappers, facts, dryRun: true, planned: counts };
605
679
  }
680
+ printFacts(facts);
681
+ printPlan(plan, false);
606
682
  const copied = applyPlan(cwd, plan);
607
- ok(`Ensured files present: ${copied} added, ${counts.skip} already there.`);
683
+ ok(`Patched: ${copied} file(s) written, ${counts.skip} already current, ${counts.review} kept for review.`);
608
684
  if (conflictEntries.length) {
609
685
  err(`${conflictEntries.length} path(s) could not be written (blocked by a file or directory of another type):`);
610
686
  conflictEntries.forEach((e) => console.log(` - ${e.rel} (${e.reason})`));
611
687
  process.exitCode = 1;
612
688
  }
689
+ printWrappers(wrappers);
613
690
  printStale(stale);
614
691
  printHandoff(flags.env, recordState(cwd, { type, env: flags.env, plan, selection, explicit, dryRun: false }));
615
- return { copied, skipped: counts.skip, conflicts: conflictEntries.length, stale, dryRun: false, planned: counts };
692
+ return { copied, skipped: counts.skip, conflicts: conflictEntries.length, stale, wrappers, facts, dryRun: false, planned: counts };
616
693
  }
617
694
 
618
695
  // Placeholders the agent fills per task or feature at runtime, so they are not bootstrap leftovers.
@@ -647,31 +724,68 @@ function scanPlaceholders(cwd, relFiles) {
647
724
  return found;
648
725
  }
649
726
 
727
+ // Read-only. Missing means a file that applies to the project and is absent. Retired paths, files that do
728
+ // not apply, and files whose relevance is still undecided are reported in their own sections.
650
729
  function cmdDoctor(cwd, flags) {
651
730
  const type = detectType(cwd, flags);
652
731
  const { selection, explicit } = resolveSelection(cwd, flags);
653
- const files = scopeFiles(templateFiles(type), flags.env)
654
- .filter((rel) => selectionKeeps(rel, selection))
655
- .map(installName);
732
+ const retired = relevance.readRetired(cwd);
733
+ const facts = relevance.detectProject(cwd, retired);
734
+ const plan = planFiles(type, cwd, { mode: 'patch', env: flags.env, selection, retired, facts });
735
+ const retiredFiles = [];
736
+ const notRelevant = [];
737
+ const undecided = [];
656
738
  const missingFiles = [];
739
+ const files = [];
657
740
  let present = 0;
658
- for (const rel of files) {
659
- if (fs.existsSync(path.join(cwd, rel))) present++;
660
- else missingFiles.push(rel);
741
+ for (const e of plan) {
742
+ if (e.action === 'retired') {
743
+ retiredFiles.push({ rel: e.rel, reason: e.reason, present: fs.existsSync(path.join(cwd, e.rel)) });
744
+ continue;
745
+ }
746
+ files.push(e.rel);
747
+ if (fs.existsSync(path.join(cwd, e.rel))) {
748
+ present++;
749
+ if (e.action === 'irrelevant') notRelevant.push(e.rel);
750
+ } else if (e.action === 'irrelevant') {
751
+ continue;
752
+ } else if (e.action === 'review') {
753
+ undecided.push({ rel: e.rel, reason: e.reason });
754
+ } else {
755
+ missingFiles.push(e.rel);
756
+ }
661
757
  }
662
758
  const placeholders = scanPlaceholders(cwd, files);
663
759
  const version = toolkitVersionStatus(cwd);
664
760
  const stale = findStale(cwd, type);
665
761
  const customized = customizedOwned(cwd);
666
- const result = { type, present, missingFiles, placeholders, version, stale, customized,
667
- selection, selectionSource: explicit ? 'flags' : CONFIG_FILE };
762
+ const wrappers = relevance.auditWrappers(cwd, plan);
763
+ const result = { type, present, missingFiles, undecided, retired: retiredFiles, notRelevant, wrappers, facts,
764
+ placeholders, version, stale, customized, selection, selectionSource: explicit ? 'flags' : CONFIG_FILE };
668
765
 
669
766
  console.log('');
670
- if (missingFiles.length === 0) ok(`All ${present} template files present.`);
671
- else {
767
+ printFacts(facts);
768
+ if (missingFiles.length === 0 && undecided.length === 0) ok(`All ${present} template files present.`);
769
+ if (missingFiles.length) {
672
770
  warn(`${missingFiles.length} missing file(s):`);
673
771
  missingFiles.forEach((f) => console.log(' - ' + f));
674
772
  }
773
+ if (undecided.length) {
774
+ warn(`${undecided.length} file(s) not installed because relevance is undecided (run patch --dry-run for the reason):`);
775
+ undecided.forEach((u) => console.log(` - ${u.rel} (${u.reason})`));
776
+ }
777
+ if (retiredFiles.length) {
778
+ info(`${retiredFiles.length} retired path(s), never re-added (${relevance.RETIRED_FILE}):`);
779
+ retiredFiles.forEach((r) => console.log(` - ${r.rel} (${r.reason})${r.present ? ' [still on disk, kept]' : ''}`));
780
+ }
781
+ if (notRelevant.length) {
782
+ info(`${notRelevant.length} present file(s) do not apply to this project. Consider retiring them in ${relevance.RETIRED_FILE} (kept as is):`);
783
+ notRelevant.forEach((f) => console.log(' - ' + f));
784
+ }
785
+ if (wrappers.length) {
786
+ warn(`${wrappers.length} harness wrapper(s) inline policy or diverge from their snippet (nothing changed):`);
787
+ wrappers.forEach((w) => console.log(` - ${w.rel} (${w.status}: ${w.reason})`));
788
+ }
675
789
  if (selection.exclude.length || selection.only.length) {
676
790
  info(`Selection (${result.selectionSource}): exclude [${selection.exclude.join(', ')}], only [${selection.only.join(', ')}].`);
677
791
  }
@@ -795,8 +909,15 @@ function cmdUpgrade(cwd, flags, deps = {}) {
795
909
  const requested = parseUpgradeTarget(flags);
796
910
  // Reads the effective selection first: a corrupt saved config or a combined --exclude/--only stops here.
797
911
  const { selection, explicit } = resolveSelection(cwd, flags);
912
+ // A malformed retirement list stops here too, before the npm lookup and before any write.
913
+ const retired = relevance.readRetired(cwd);
798
914
  const target = resolveUpgradeVersion(requested, deps.npmView || npmQuery);
799
915
  info(`Resolved ${PKG_NAME}@${target} (installed toolkit: ${currentVersion()}).`);
916
+ const retirementActive = retired.entries.length > 0 || retired.trackers !== undefined;
917
+ if (retirementActive && compareVersions(target, RELEVANCE_MIN) < 0) {
918
+ throw new Error(`${PKG_NAME}@${target} predates retirement support and would re-add the paths and tracker `
919
+ + `choices recorded in ${relevance.RETIRED_FILE}. Pin ${RELEVANCE_MIN} or later.`);
920
+ }
800
921
  const selectionActive = selection.exclude.length > 0 || selection.only.length > 0;
801
922
  if (flags.dryRun && compareVersions(target, DRY_RUN_UPGRADE_MIN) < 0) {
802
923
  throw new Error(`--dry-run needs ${PKG_NAME} >= ${DRY_RUN_UPGRADE_MIN}; ${target} has no dry run and would write. Pin a newer version.`);
@@ -812,7 +933,7 @@ function cmdUpgrade(cwd, flags, deps = {}) {
812
933
  if (writeState(path.join(cwd, CONFIG_FILE), text) === 'write') writeText(cwd, CONFIG_FILE, text);
813
934
  }
814
935
  if (flags.force) warn('--force: existing files will be overwritten with the latest templates. Back up your customisations first.');
815
- else info('Adding missing files only; your existing files are kept. Pass --force to overwrite them.');
936
+ else info('Applying the patch plan: adds files that apply and updates files you never changed; edited files are kept. Pass --force to overwrite them.');
816
937
  info(`Running pinned ${PKG_NAME}@${target}${flags.dryRun ? ' (dry run, nothing written)' : ''}...`);
817
938
  const run = deps.spawn || spawnSync;
818
939
  const r = run('npx', upgradeArgs(flags, target), { cwd, stdio: 'inherit', timeout: 120000 });
@@ -939,8 +1060,11 @@ Usage:
939
1060
  --dry-run lists adds, overwrites, skips and conflicts; writes nothing; exits 1 if there are conflicts.
940
1061
 
941
1062
  npx nitm-ai-dev-toolkit patch [--monorepo|--repo] [--env <env>] [--exclude <groups>|--only <groups>] [--dry-run]
942
- Add only missing template files (never overwrites existing ones), then
943
- re-emit the bootstrap handoff so your AI agent can finish configuration.
1063
+ Classify every template file against the project, print the plan, then write it: add
1064
+ files that apply, update files you never changed. Files that differ from the template are
1065
+ kept and listed for review; files that do not apply (other trackers, Playwright, Prisma,
1066
+ empty MCP configs) and retired paths (.nitm/retired.json) are never added. Harness wrappers
1067
+ that inline policy are reported, not changed. Then re-emit the bootstrap handoff.
944
1068
  A given --exclude/--only is saved to .nitm/toolkit.json and used by later patch/doctor runs.
945
1069
 
946
1070
  npx nitm-ai-dev-toolkit upgrade [version] [--env <env>] [--force] [--dry-run] [--exclude <groups>|--only <groups>]
@@ -951,9 +1075,10 @@ Usage:
951
1075
  --dry-run runs the pinned version's dry run (needs ${DRY_RUN_UPGRADE_MIN} or newer).
952
1076
 
953
1077
  npx nitm-ai-dev-toolkit doctor [--monorepo|--repo] [--env <env>]
954
- Inspect the current install: missing files, unresolved {{PLACEHOLDER}}
955
- values, .toolkit-version against the toolkit version, and stale toolkit files
956
- (reported, never deleted).
1078
+ Inspect the current install (read-only): missing files, retired paths (.nitm/retired.json,
1079
+ never counted as missing), files that do not apply to the project, files whose relevance is
1080
+ undecided, harness wrappers that inline policy, unresolved {{PLACEHOLDER}} values,
1081
+ .toolkit-version against the toolkit version, and stale toolkit files (reported, never deleted).
957
1082
 
958
1083
  npx nitm-ai-dev-toolkit omo-slim-starter install
959
1084
  Run \`npx nitm-opencode-starter install\` to scaffold the oh-my-opencode-slim
@@ -1074,6 +1199,7 @@ module.exports = {
1074
1199
  resolveUpgradeVersion,
1075
1200
  compareVersions,
1076
1201
  DRY_RUN_UPGRADE_MIN,
1202
+ RELEVANCE_MIN,
1077
1203
  cmdOmoStarter,
1078
1204
  cmdFactory,
1079
1205
  ENV_DIRS,
@@ -1084,4 +1210,10 @@ module.exports = {
1084
1210
  HANDOFF_FILE,
1085
1211
  CONFIG_FILE,
1086
1212
  MANIFEST_FILE,
1213
+ RETIRED_FILE: relevance.RETIRED_FILE,
1214
+ readRetired: relevance.readRetired,
1215
+ detectProject: relevance.detectProject,
1216
+ relevanceOf: relevance.relevanceOf,
1217
+ auditWrappers: relevance.auditWrappers,
1218
+ countActions,
1087
1219
  };