devlyn-cli 4.2.20 → 4.2.21

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 (3) hide show
  1. package/README.md +2 -2
  2. package/bin/devlyn.js +50 -111
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -35,7 +35,7 @@ That's it. The installer asks:
35
35
 
36
36
  In a project, AGENTS.md readers load the skills from `.agents/skills/`; Claude Code loads only `.claude/skills/` and reads AGENTS.md when the project has no CLAUDE.md; a CLAUDE.md the installer creates imports an existing AGENTS.md (`@AGENTS.md`). Global installs skills only: `~/.agents/skills/` (omp, Pi, Grok) and `~/.codex/skills/` (Codex), plus `~/.claude/skills/` for Claude Code. Every target gets the `devlyn-ideate` skill and the `devlyn-engines` utility. In Codex / omp / Pi, invoke them as skills (`$devlyn-ideate`); in Claude Code and Grok they're slash commands (`/devlyn-ideate`). Rerunning refreshes skills and the managed instruction block while preserving project rules outside it. See [Migration from earlier versions](#migration-from-earlier-versions) for legacy migration and merge recovery.
37
37
 
38
- Without prompts, `npx devlyn-cli -y` installs AGENTS.md + `.agents/skills/` plus every target this project already has; add `--claude` for Claude Code. With `--global` it installs for every project on this machine, plus `~/.claude/skills/` with `--claude` or when it already has a devlyn install. After a project install or update, the installer names the changed files to commit, including installed files a `.gitignore` hides, so teammates and `/devlyn-ideate` drains can use them.
38
+ Without prompts, `npx devlyn-cli -y` installs AGENTS.md + `.agents/skills/` plus every target this project already has; add `--claude` for Claude Code. With `--global` it installs for every project on this machine, plus `~/.claude/skills/` with `--claude` or when it already has a devlyn install. The installer leaves Git to you: it prints no commit guidance after a project install or update. When Git ignores some, but not all, files of an installed skill folder, it prints one line with their count and an example ignore rule.
39
39
 
40
40
  `npx devlyn-cli update` — Update every devlyn install already here (this project and your global skill folders) to this version; installs nothing new.
41
41
 
@@ -62,7 +62,7 @@ Give ideate an intent or a document. It writes a loop package — a meta-prompt
62
62
  | `/devlyn-ideate status` | Pending, active, accepted and failed counts; the next runnable task; delivery and recovery blockers. |
63
63
  | `/devlyn-ideate drain` | Runs the queue serially and hands-free. |
64
64
 
65
- A bare intent is planned and, when your request authorizes the work, added and drained without another confirmation; no arguments shows status. Questions come only when the answer changes behavior, scope, data semantics, acceptance or delivery, each with a recommended answer; `--autonomous` plans without questions, recording ordinary low-consequence, reversible defaults within the request, including user-visible details; material ambiguity in intended outcomes, persistent data or state semantics, or public surface beyond the request stops planning with a concrete question. Each task runs in its own worktree with the executor pinned by `/devlyn-engines` (default: the CLI you opened; Pi and Grok need a pin) under your installed CLAUDE.md/AGENTS.md instructions. Tasks start from committed state, the branch you added the loop on or the remote base for `auto`/`pr`, so commit the installer's changes and whatever a plan depends on before adding a loop, and push them for `auto`/`pr`. It is marked `[x]` only when its declared checks pass on its committed source and its required reviews cover it: the checks run through the drain's runner (a wholly clean run the executor lists for that source is reused; the drain runs any other check itself), and review coverage is attested by records the executor submits; a failed task becomes `[F]` with its reason and blocks only its dependents. An interrupted drain resumes without repeating accepted work. `--local-only` (or `--no-push`) keeps delivery local: each accepted task stays on its `devlyn/<loop-id>/<task-id>` branch in a retained worktree, nothing is merged or pushed, and the drain report's `Bring into` command is `git merge --ff <latest accepted task branch>`.
65
+ A bare intent is planned and, when your request authorizes the work, added and drained without another confirmation; no arguments shows status. Questions come only when the answer changes behavior, scope, data semantics, acceptance or delivery, each with a recommended answer; `--autonomous` plans without questions, recording ordinary low-consequence, reversible defaults within the request, including user-visible details; material ambiguity in intended outcomes, persistent data or state semantics, or public surface beyond the request stops planning with a concrete question. Each task runs in its own worktree with the executor pinned by `/devlyn-engines` (default: the CLI you opened; Pi and Grok need a pin) under your installed CLAUDE.md/AGENTS.md instructions. Tasks start from committed state, the branch you added the loop on or the remote base for `auto`/`pr`, so a task's worktree holds only what is committed there. A task would run without installed CLAUDE.md/AGENTS.md that exist only in this checkout, so add stops while they differ from HEAD, and drain waits while they differ from the task's start commit. It is marked `[x]` only when its declared checks pass on its committed source and its required reviews cover it: the checks run through the drain's runner (a wholly clean run the executor lists for that source is reused; the drain runs any other check itself), and review coverage is attested by records the executor submits; a failed task becomes `[F]` with its reason and blocks only its dependents. An interrupted drain resumes without repeating accepted work. `--local-only` (or `--no-push`) keeps delivery local: each accepted task stays on its `devlyn/<loop-id>/<task-id>` branch in a retained worktree, nothing is merged or pushed, and the drain report's `Bring into` command is `git merge --ff <latest accepted task branch>`.
66
66
 
67
67
  ### Delivery
68
68
 
package/bin/devlyn.js CHANGED
@@ -357,17 +357,16 @@ function listContents() {
357
357
  // are in place. An optional skill found under an old spelling is installed under its new name
358
358
  // before the old copy goes, so an interrupted migration never loses it. An unedited 0.6.0-0.7.1
359
359
  // copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
360
- function refreshRenamedSkills(skillsDir, written, installedSkills) {
360
+ function refreshRenamedSkills(skillsDir, installedSkills) {
361
361
  const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
362
362
  const refreshed = [];
363
363
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
364
364
  const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
365
365
  if (found.length > 0 && optional.has(newName) && canRefreshRenamed(skillsDir, newName)) {
366
- installOptionalSkillInto(skillsDir, newName, written, installedSkills);
366
+ installOptionalSkillInto(skillsDir, newName, installedSkills);
367
367
  refreshed.push(newName);
368
368
  } else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
369
369
  refreshPreStandardCopy(skillsDir, newName);
370
- written.push(path.join(skillsDir, newName));
371
370
  installedSkills.set(path.join(skillsDir, newName), [path.join(skillsDir, newName, 'SKILL.md')]);
372
371
  refreshed.push(newName);
373
372
  }
@@ -609,23 +608,22 @@ function singleSelect(message, items, initial) {
609
608
  }
610
609
 
611
610
  // After preflight has cleared every selected addon before any change.
612
- function installLocalSkill(skillName, roots, written = [], installedSkills = new Map()) {
611
+ function installLocalSkill(skillName, roots, installedSkills = new Map()) {
613
612
  if (!fs.existsSync(path.join(OPTIONAL_SKILLS_SOURCE, skillName))) {
614
613
  log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
615
614
  return false;
616
615
  }
617
616
  log(`\n🛠️ Installing ${skillName}...`, 'cyan');
618
617
  for (const root of roots) {
619
- installOptionalSkillInto(root, skillName, written, installedSkills);
618
+ installOptionalSkillInto(root, skillName, installedSkills);
620
619
  writeInstallMarker(root, [skillName], readInstallManifest(root));
621
- written.push(path.join(root, DEVLYN_INSTALL_MARKER));
622
620
  }
623
621
  return true;
624
622
  }
625
623
 
626
624
  // One optional skill into one skill-loader directory, replacing any older copy of it. A
627
625
  // copy under its name before the 4.0.0 rename goes only once the new one is complete.
628
- function installOptionalSkillInto(target, skillName, written, installedSkills) {
626
+ function installOptionalSkillInto(target, skillName, installedSkills) {
629
627
  const dest = path.join(target, skillName);
630
628
  const stage = fs.mkdtempSync(path.join(target, '.devlyn-skill-'));
631
629
  const stagedSkill = path.join(stage, skillName);
@@ -635,7 +633,6 @@ function installOptionalSkillInto(target, skillName, written, installedSkills) {
635
633
  fs.rmSync(dest, { recursive: true, force: true });
636
634
  fs.renameSync(stagedSkill, dest);
637
635
  installedSkills.set(dest, files.map((file) => path.join(dest, path.relative(stagedSkill, file))));
638
- written.push(dest);
639
636
  } finally {
640
637
  fs.rmSync(stage, { recursive: true, force: true });
641
638
  }
@@ -693,7 +690,7 @@ function installAddon(addon) {
693
690
 
694
691
  // The core skills into one skill root, replacing older copies and retired skills, then the
695
692
  // marker that records a complete install.
696
- function installCoreSkills(skillsDir, written = [], installedSkills = new Map()) {
693
+ function installCoreSkills(skillsDir, installedSkills = new Map()) {
697
694
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
698
695
  log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
699
696
  fs.mkdirSync(skillsDir, { recursive: true });
@@ -706,12 +703,10 @@ function installCoreSkills(skillsDir, written = [], installedSkills = new Map())
706
703
  // Full replace: copyRecursive is an overlay, so stale files would otherwise persist.
707
704
  fs.rmSync(dest, { recursive: true, force: true });
708
705
  installedSkills.set(dest, copyRecursive(src, dest, skillsDir));
709
- written.push(dest);
710
706
  }
711
707
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
712
- const refreshed = refreshRenamedSkills(skillsDir, written, installedSkills);
708
+ const refreshed = refreshRenamedSkills(skillsDir, installedSkills);
713
709
  writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
714
- written.push(path.join(skillsDir, DEVLYN_INSTALL_MARKER));
715
710
  }
716
711
 
717
712
  function readProjectGitignore() {
@@ -763,9 +758,7 @@ function updateProjectGitignore(markerEntry) {
763
758
  if (!gitignoreContent.equals(original)) {
764
759
  fs.writeFileSync(gitignorePath, gitignoreContent);
765
760
  log(' → .gitignore (updated project install rules)', 'dim');
766
- return true;
767
761
  }
768
- return false;
769
762
  }
770
763
 
771
764
  // With the Claude target in the same run, an AGENTS.md that links to this project's CLAUDE.md (a
@@ -786,18 +779,16 @@ function agentsMdIsClaudeMd() {
786
779
  }
787
780
 
788
781
  function installAgentsProject(withClaude, installedSkills = new Map()) {
789
- const written = [];
790
782
  if (!agentsMdIsClaudeMd()) {
791
- if (updateInstructions('AGENTS.md')) written.push('AGENTS.md');
783
+ updateInstructions('AGENTS.md');
792
784
  } else if (withClaude) {
793
785
  log(' → AGENTS.md is CLAUDE.md here, so it gets the devlyn block written there', 'dim');
794
786
  } else {
795
787
  throw new InstructionError('AGENTS.md is CLAUDE.md under another name here. Choose CLAUDE.md as well '
796
788
  + '(npx devlyn-cli -y --claude) so the devlyn block is written once, into CLAUDE.md.');
797
789
  }
798
- installCoreSkills(skillRoots('agents', false)[0], written, installedSkills);
799
- if (updateProjectGitignore('.agents/skills/.devlyn-install.json')) written.push('.gitignore');
800
- return written;
790
+ installCoreSkills(skillRoots('agents', false)[0], installedSkills);
791
+ updateProjectGitignore('.agents/skills/.devlyn-install.json');
801
792
  }
802
793
 
803
794
  // Whether Claude Code reads the devlyn defaults of AGENTS.md through an `@AGENTS.md` import in this CLAUDE.md text.
@@ -821,9 +812,8 @@ function installClaudeInstructions() {
821
812
  throw new InstructionError('Claude Code reads AGENTS.md here, and its devlyn defaults need an update. Codex, omp, Pi and Grok '
822
813
  + 'read them too, with .agents/skills, so choose AGENTS.md as well (npx devlyn-cli -y --claude) to update both together.');
823
814
  }
824
- const changed = updateInstructions('CLAUDE.md', initial, !imported);
815
+ updateInstructions('CLAUDE.md', initial, !imported);
825
816
  if (imported) log(' → CLAUDE.md imports AGENTS.md, which holds the devlyn block', 'dim');
826
- return changed;
827
817
  }
828
818
 
829
819
  // Project CLAUDE.md and .claude/: skills and settings.
@@ -852,9 +842,9 @@ function installClaudeCore(installedSkills = new Map()) {
852
842
  && (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env))) {
853
843
  throw unmergeable('env must be a JSON object');
854
844
  }
855
- const written = installClaudeInstructions() ? ['CLAUDE.md'] : [];
856
- installCoreSkills(skillsDir, written, installedSkills);
857
- if (updateProjectGitignore('.claude/skills/.devlyn-install.json')) written.push('.gitignore');
845
+ installClaudeInstructions();
846
+ installCoreSkills(skillsDir, installedSkills);
847
+ updateProjectGitignore('.claude/skills/.devlyn-install.json');
858
848
 
859
849
  // One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
860
850
  let settingsChanged = false;
@@ -874,7 +864,6 @@ function installClaudeCore(installedSkills = new Map()) {
874
864
  if (settingsChanged) {
875
865
  replaceFile(settingsPath, JSON.stringify(settings, null, 2) + '\n');
876
866
  log(' → settings.json', 'dim');
877
- written.push('.claude/settings.json');
878
867
  }
879
868
  // Their ownership cannot be established, so the other retired settings stay; name the ones present.
880
869
  const retired = [
@@ -884,7 +873,6 @@ function installClaudeCore(installedSkills = new Map()) {
884
873
  if (retired.length > 0) {
885
874
  log(` Retired devlyn settings may remain in .claude/settings.json: ${retired.join(', ')} — delete those you did not add yourself.`, 'yellow');
886
875
  }
887
- return written;
888
876
  }
889
877
 
890
878
  // Replace this directory entry, never a hardlink's other names.
@@ -991,7 +979,7 @@ function withUserSkillLocks(roots, action, { existingOnly = false } = {}) {
991
979
  }
992
980
  }
993
981
 
994
- // Installs the targets in one scope; returns the skill roots written.
982
+ // Installs the targets in one scope; returns the installed skill roots.
995
983
  function install(targets, global, optionalNames = [], plan, afterInstall = () => {}) {
996
984
  // In the home folder CLAUDE.md, AGENTS.md (agents read a parent folder's too) and
997
985
  // .claude/settings.json would apply to every project. Same folder by identity, so a link or
@@ -1004,30 +992,29 @@ function install(targets, global, optionalNames = [], plan, afterInstall = () =>
1004
992
  }
1005
993
  const roots = targets.flatMap((target) => skillRoots(target, global));
1006
994
  return withUserSkillLocks(global ? roots : [], () => {
1007
- const written = [];
1008
995
  const installedSkills = new Map();
1009
996
  if (!global) readProjectGitignore();
1010
997
  plan ??= preflightSkills(roots, optionalNames);
1011
998
  // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
1012
999
  for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
1013
- if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root, [], installedSkills));
1014
- else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills));
1015
- else written.push(...installClaudeCore(installedSkills));
1000
+ if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root, installedSkills));
1001
+ else if (target === 'agents') installAgentsProject(targets.includes('claude'), installedSkills);
1002
+ else installClaudeCore(installedSkills);
1016
1003
  }
1017
- for (const name of optionalNames) installLocalSkill(name, roots, written, installedSkills);
1004
+ for (const name of optionalNames) installLocalSkill(name, roots, installedSkills);
1018
1005
  afterInstall();
1019
1006
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1020
1007
  if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
1021
1008
  noticeGlobalDrift(roots);
1022
- cleanupScope(global ? os.homedir() : projectDir(), global, written,
1009
+ cleanupScope(global ? os.homedir() : projectDir(), global,
1023
1010
  global ? [] : targets.map((target) => target === 'agents' ? 'AGENTS.md' : 'CLAUDE.md'));
1024
- if (!global) noticeProjectChanges(written, installedSkills);
1011
+ if (!global) noticeIgnoredInstallFiles(installedSkills);
1025
1012
  return roots;
1026
1013
  });
1027
1014
  }
1028
1015
 
1029
1016
  // Cleanup visits existing paths in the command's scope; it never creates an install.
1030
- function cleanupScope(base, global, written, activeInstructions = []) {
1017
+ function cleanupScope(base, global, activeInstructions = []) {
1031
1018
  const warned = new Set();
1032
1019
  let found = false;
1033
1020
  const warning = (file, error) => {
@@ -1051,7 +1038,6 @@ function cleanupScope(base, global, written, activeInstructions = []) {
1051
1038
  if (!realParents(file) || !fs.lstatSync(file, { throwIfNoEntry: false })) return false;
1052
1039
  found = true;
1053
1040
  fs.rmSync(file, { recursive: true }); // lstat/realParents never follows a link; rm removes the link itself.
1054
- written.push(file);
1055
1041
  log(` ✕ ${path.relative(base, file)} (removed)`, 'dim');
1056
1042
  return true;
1057
1043
  });
@@ -1103,7 +1089,6 @@ function cleanupScope(base, global, written, activeInstructions = []) {
1103
1089
  const data = JSON.parse(fs.readFileSync(marker, 'utf8'));
1104
1090
  for (const name of removed) delete data.skills[name];
1105
1091
  replaceFile(marker, JSON.stringify(data, null, 2) + '\n');
1106
- written.push(marker);
1107
1092
  });
1108
1093
  }
1109
1094
  };
@@ -1132,7 +1117,6 @@ function cleanupScope(base, global, written, activeInstructions = []) {
1132
1117
  if (settings && typeof settings === 'object' && cleanRetiredSettings(settings)) {
1133
1118
  found = true;
1134
1119
  replaceFile(settingsFile, JSON.stringify(settings, null, 2) + '\n');
1135
- written.push(settingsFile);
1136
1120
  log(' ✕ .claude/settings.json retired entries (removed)', 'dim');
1137
1121
  }
1138
1122
  });
@@ -1144,75 +1128,34 @@ function cleanupScope(base, global, written, activeInstructions = []) {
1144
1128
  const evaluatorOnly = ['CLAUDE.md', 'AGENTS.md'].includes(name);
1145
1129
  if (!updateInstructions(name, '', false, false, base, evaluatorOnly)) return;
1146
1130
  found = true;
1147
- if (updateInstructions(name, '', false, true, base, evaluatorOnly)) written.push(file);
1131
+ updateInstructions(name, '', false, true, base, evaluatorOnly);
1148
1132
  });
1149
1133
  }
1150
1134
  return found;
1151
1135
  }
1152
1136
 
1153
- // Advisory only: a successful install stays successful when Git is unavailable or fails.
1154
- function noticeProjectChanges(paths, installedSkills = new Map()) {
1155
- if (!paths.length && !installedSkills.size) return;
1137
+ // Advisory only: check files this run copied, grouped by skill; a wholly ignored skill
1138
+ // folder is deliberate. check-ignore omits tracked files, and Git failures stay silent.
1139
+ function noticeIgnoredInstallFiles(installedSkills) {
1156
1140
  const cwd = projectDir();
1157
- const relative = (name) => path.relative(cwd, path.resolve(cwd, name)).split(path.sep).join('/');
1158
- paths = [...new Set(paths.map(relative))];
1141
+ const groups = [...installedSkills.values()].map((files) =>
1142
+ files.map((file) => path.relative(cwd, file).split(path.sep).join('/')));
1143
+ const files = groups.flat();
1144
+ if (!files.length) return;
1159
1145
  const env = { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' };
1160
1146
  const git = (args, input) => spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env, input });
1161
- const repo = git(['rev-parse', '--show-toplevel']);
1162
- if (repo.error || repo.status !== 0) return;
1163
- const status = git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames',
1164
- '--', ...paths.map((name) => `:(icase,literal)${name}`)]);
1165
- if (status.error || status.status !== 0) return;
1166
- // Porcelain paths are repository-relative, even when installing in a nested project.
1167
- const changed = status.stdout.split('\0').filter(Boolean).map((entry) =>
1168
- path.relative(cwd, path.resolve(repo.stdout.replace(/\r?\n$/, ''), entry.slice(3))).split(path.sep).join('/'));
1169
- // Keep the installed folder/file scope, using Git's spelling on case-insensitive volumes.
1170
- const pending = [...new Set(paths.flatMap((name) => [...new Set(changed
1171
- .map((file) => file.split('/').slice(0, name.split('/').length).join('/')))]
1172
- .filter((file) => {
1173
- if (file === name) return true;
1174
- if (file.toLowerCase() !== name.toLowerCase()) return false;
1175
- // Case folding alone can include independently edited files on case-sensitive volumes.
1176
- // Removed paths have no identity left to compare, so require their exact spelling.
1177
- try {
1178
- const written = fs.lstatSync(path.resolve(cwd, name), { bigint: true, throwIfNoEntry: false });
1179
- const reported = fs.lstatSync(path.resolve(cwd, file), { bigint: true, throwIfNoEntry: false });
1180
- return written && reported && written.dev === reported.dev && written.ino === reported.ino;
1181
- } catch (error) {
1182
- log(`Cannot compare Git path ${file} with installed path ${name}: ${error.message}`, 'yellow');
1183
- return false;
1184
- }
1185
- })))];
1186
- // Check only files this run copied, grouped by skill: ignoring a whole skill is deliberate.
1187
- // check-ignore omits tracked files, so a complete committed install stays quiet on reruns.
1188
- const groups = [...installedSkills.values()].map((files) => files.map(relative));
1189
- const files = groups.flat();
1190
- let ignored = [];
1191
- let rule;
1192
- if (files.length) {
1193
- const result = git(['check-ignore', '-z', '--stdin'], files.join('\0') + '\0');
1194
- if (result.error || ![0, 1].includes(result.status)) return;
1195
- const matches = new Set(result.stdout.split('\0').filter(Boolean));
1196
- ignored = [...new Set(groups.flatMap((group) => {
1197
- const hidden = group.filter((file) => matches.has(file));
1198
- return hidden.length === group.length ? [] : hidden;
1199
- }))];
1200
- if (ignored.length) {
1201
- const verbose = git(['check-ignore', '-v', '-z', '--stdin'], ignored[0] + '\0');
1202
- if (verbose.error || verbose.status !== 0) return;
1203
- const [source, line, pattern] = verbose.stdout.split('\0');
1204
- rule = `${source}:${line}: ${pattern}`;
1205
- }
1206
- }
1207
- if (!pending.length && !ignored.length) return;
1208
- const quote = (name) => /^[a-zA-Z0-9_./-]+$/.test(name) ? name : `'${name.replace(/'/g, "'\\''")}'`;
1209
- log('Optional — commit (and push) these changes if teammates or /devlyn-ideate task worktrees should use them:', 'yellow');
1210
- if (ignored.length) log(`.gitignore hides ${ignored.length} installed files (${rule}).`, 'yellow');
1211
- const commands = [];
1212
- if (pending.length) commands.push(`git add -- ${pending.map(quote).join(' ')}`);
1213
- if (ignored.length) commands.push(`git add -f -- ${ignored.map(quote).join(' ')}`);
1214
- commands.push(`git commit -m "chore(devlyn): update devlyn-cli to ${PKG.version}"`);
1215
- log(` ${commands.join(' && ')}`, 'dim');
1147
+ const result = git(['check-ignore', '-z', '--stdin'], files.join('\0') + '\0');
1148
+ if (result.error || ![0, 1].includes(result.status)) return;
1149
+ const matches = new Set(result.stdout.split('\0').filter(Boolean));
1150
+ const ignored = [...new Set(groups.flatMap((group) => {
1151
+ const hidden = group.filter((file) => matches.has(file));
1152
+ return hidden.length === group.length ? [] : hidden;
1153
+ }))];
1154
+ if (!ignored.length) return;
1155
+ const verbose = git(['check-ignore', '-v', '-z', '--stdin'], ignored[0] + '\0');
1156
+ if (verbose.error || verbose.status !== 0) return;
1157
+ const [source, line, pattern] = verbose.stdout.split('\0');
1158
+ log(`Git ignores ${ignored.length} installed file${ignored.length === 1 ? '' : 's'} (example rule: ${source}:${line}: ${pattern}).`, 'dim');
1216
1159
  }
1217
1160
 
1218
1161
  // A devlyn install in a user root this run does not refresh drifts and can shadow the
@@ -1235,8 +1178,7 @@ function noticeGlobalDrift(installed) {
1235
1178
  }
1236
1179
 
1237
1180
  // Skip project setup in the home folder, where instructions would affect every project.
1238
- // Lock user roots, preflight all roots, check versions, then write; user roots go first
1239
- // so the project's commit guidance remains the last output.
1181
+ // Lock user roots, preflight all roots, check versions, then write.
1240
1182
  function update() {
1241
1183
  showLogo();
1242
1184
  log('─'.repeat(44), 'dim');
@@ -1273,10 +1215,9 @@ function update() {
1273
1215
  const projectRoots = targets.flatMap((target) => skillRoots(target, false));
1274
1216
  const roots = [...userRoots, ...projectRoots];
1275
1217
  if (!roots.length) {
1276
- const written = [];
1277
- const userCleanup = cleanupScope(os.homedir(), true, written);
1278
- const projectCleanup = (home?.dev !== here.dev || home?.ino !== here.ino) && cleanupScope(projectDir(), false, written);
1279
- if (userCleanup || projectCleanup) { noticeProjectChanges(written.filter((file) => !path.isAbsolute(file) || file.startsWith(projectDir() + path.sep))); return; }
1218
+ const userCleanup = cleanupScope(os.homedir(), true);
1219
+ const projectCleanup = (home?.dev !== here.dev || home?.ino !== here.ino) && cleanupScope(projectDir(), false);
1220
+ if (userCleanup || projectCleanup) return;
1280
1221
  throw new InstructionError(['No devlyn install here to update. Run npx devlyn-cli to install.', ...linkNotices].join('\n'));
1281
1222
  }
1282
1223
 
@@ -1305,17 +1246,15 @@ function update() {
1305
1246
  + newer.join('\n') + '\nRun npx devlyn-cli@latest update.');
1306
1247
  }
1307
1248
 
1308
- userRoots.forEach((root) => installCoreSkills(root, [], new Map()));
1309
- if (targets.length) install(targets, false, [], plan, () => cleanupScope(os.homedir(), true, []));
1249
+ userRoots.forEach((root) => installCoreSkills(root));
1250
+ if (targets.length) install(targets, false, [], plan, () => cleanupScope(os.homedir(), true));
1310
1251
  else {
1311
- cleanupScope(os.homedir(), true, []);
1252
+ cleanupScope(os.homedir(), true);
1312
1253
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1313
1254
  noticeGlobalDrift(userRoots);
1314
1255
  }
1315
1256
  if (!targets.length && (home?.dev !== here.dev || home?.ino !== here.ino)) {
1316
- const written = [];
1317
- cleanupScope(projectDir(), false, written);
1318
- noticeProjectChanges(written);
1257
+ cleanupScope(projectDir(), false);
1319
1258
  }
1320
1259
  });
1321
1260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.20",
3
+ "version": "4.2.21",
4
4
  "description": "Loop & harness engineering for AI coding agents (Claude Code, Codex, and any AGENTS.md-driven agent) — core engineering principles for CLAUDE.md/AGENTS.md and a loop designer that splits one intent into tasks agents drain hands-free",
5
5
  "homepage": "https://github.com/fysoul17/devlyn-cli#readme",
6
6
  "bin": {