devlyn-cli 4.2.7 → 4.2.9

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
@@ -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.
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.
39
39
 
40
40
  ---
41
41
 
package/bin/devlyn.js CHANGED
@@ -3,13 +3,13 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
- const { execSync } = require('child_process');
6
+ const { execSync, spawnSync } = require('child_process');
7
7
 
8
8
  const CONFIG_SOURCE = path.join(__dirname, '..', 'config');
9
9
  const OPTIONAL_SKILLS_SOURCE = path.join(__dirname, '..', 'optional-skills');
10
10
  const PKG = require('../package.json');
11
11
  const { updateInstructions, InstructionError, holdsDevlynDefaults, importsAgentsMd } = require('./instructions');
12
- const { contentHash, fingerprint, isIgnoredEntry, trackedPaths } = require('./skill-ownership');
12
+ const { contentHash, fingerprint, gitEnvironment, isIgnoredEntry, trackedPaths } = require('./skill-ownership');
13
13
  const SKILL_HISTORY = require('./skill-history.json');
14
14
 
15
15
  // The devlyn skill bundle installed into every skill-capable agent's loader
@@ -346,13 +346,14 @@ function listContents() {
346
346
  }
347
347
 
348
348
  // Old and retired skills in one skill root; nothing outside it.
349
- function cleanupDeprecated(skillsDir) {
349
+ function cleanupDeprecated(skillsDir, written) {
350
350
  let removed = 0;
351
351
  const deprecated = DEPRECATED_DIRS.flatMap((relPath) =>
352
352
  legacySkillPaths(skillsDir, path.basename(relPath)).map((fullPath) => [relPath, fullPath]));
353
353
  for (const [relPath, fullPath] of deprecated) {
354
354
  if (fs.existsSync(fullPath)) {
355
355
  fs.rmSync(fullPath, { recursive: true });
356
+ written.push(fullPath);
356
357
  log(` ✕ ${relPath}/ (removed)`, 'dim');
357
358
  removed++;
358
359
  }
@@ -361,6 +362,7 @@ function cleanupDeprecated(skillsDir) {
361
362
  const fullPath = path.join(skillsDir, name);
362
363
  if (isShippedCopy(fullPath, hashes)) {
363
364
  fs.rmSync(fullPath, { recursive: true });
365
+ written.push(fullPath);
364
366
  log(` ✕ skills/${name}/ (removed)`, 'dim');
365
367
  removed++;
366
368
  }
@@ -372,27 +374,30 @@ function cleanupDeprecated(skillsDir) {
372
374
  // are in place. An optional skill found under an old spelling is installed under its new name
373
375
  // before the old copy goes, so an interrupted migration never loses it. An unedited 0.6.0-0.7.1
374
376
  // copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
375
- function retireRenamedSkills(skillsDir) {
377
+ function retireRenamedSkills(skillsDir, written, installedSkills) {
376
378
  const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
377
379
  const refreshed = [];
378
380
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
379
381
  const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
380
382
  if (found.length > 0 && optional.has(newName)) {
381
- installOptionalSkillInto(skillsDir, newName);
383
+ installOptionalSkillInto(skillsDir, newName, written, installedSkills);
382
384
  refreshed.push(newName);
383
385
  } else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
384
386
  refreshPreStandardCopy(skillsDir, newName);
387
+ written.push(path.join(skillsDir, newName));
388
+ installedSkills.set(path.join(skillsDir, newName), [path.join(skillsDir, newName, 'SKILL.md')]);
385
389
  refreshed.push(newName);
386
390
  }
387
391
  for (const fullPath of found) {
388
392
  fs.rmSync(fullPath, { recursive: true, force: true });
393
+ written.push(fullPath);
389
394
  log(` ✕ ${path.basename(fullPath)}/ (renamed to ${newName})`, 'dim');
390
395
  }
391
396
  }
392
397
  return refreshed;
393
398
  }
394
399
 
395
- function copyRecursive(src, dest, baseDir) {
400
+ function copyRecursive(src, dest, baseDir, files = []) {
396
401
  const stats = fs.statSync(src);
397
402
 
398
403
  if (stats.isDirectory()) {
@@ -401,7 +406,7 @@ function copyRecursive(src, dest, baseDir) {
401
406
  }
402
407
 
403
408
  for (const item of fs.readdirSync(src)) {
404
- copyRecursive(path.join(src, item), path.join(dest, item), baseDir);
409
+ copyRecursive(path.join(src, item), path.join(dest, item), baseDir, files);
405
410
  }
406
411
  } else {
407
412
  const destDir = path.dirname(dest);
@@ -409,8 +414,10 @@ function copyRecursive(src, dest, baseDir) {
409
414
  fs.mkdirSync(destDir, { recursive: true });
410
415
  }
411
416
  fs.copyFileSync(src, dest);
417
+ files.push(dest);
412
418
  log(` → ${path.relative(baseDir, dest)}`, 'dim');
413
419
  }
420
+ return files;
414
421
  }
415
422
 
416
423
  function clearInstallMarker(skillsDir) {
@@ -610,30 +617,34 @@ function singleSelect(message, items, initial) {
610
617
  });
611
618
  }
612
619
 
613
- // After install(), whose preflight already cleared every selected addon before any change.
614
- function installLocalSkill(skillName, roots) {
620
+ // After preflight has cleared every selected addon before any change.
621
+ function installLocalSkill(skillName, roots, written = [], installedSkills = new Map()) {
615
622
  if (!fs.existsSync(path.join(OPTIONAL_SKILLS_SOURCE, skillName))) {
616
623
  log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
617
624
  return false;
618
625
  }
619
626
  log(`\n🛠️ Installing ${skillName}...`, 'cyan');
620
627
  for (const root of roots) {
621
- installOptionalSkillInto(root, skillName);
628
+ installOptionalSkillInto(root, skillName, written, installedSkills);
622
629
  writeInstallMarker(root, [skillName], readInstallManifest(root));
630
+ written.push(path.join(root, DEVLYN_INSTALL_MARKER));
623
631
  }
624
632
  return true;
625
633
  }
626
634
 
627
635
  // One optional skill into one skill-loader directory, replacing any older copy of it. A
628
636
  // copy under its name before the 4.0.0 rename goes only once the new one is complete.
629
- function installOptionalSkillInto(target, skillName) {
637
+ function installOptionalSkillInto(target, skillName, written, installedSkills) {
630
638
  const dest = path.join(target, skillName);
631
639
  fs.rmSync(dest, { recursive: true, force: true });
632
- copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), dest, target);
640
+ installedSkills.set(dest, copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), dest, target));
633
641
  assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, target, [skillName]);
642
+ written.push(dest);
634
643
  const oldName = Object.keys(RENAMED_SKILLS).find((name) => RENAMED_SKILLS[name] === skillName);
635
644
  for (const fullPath of oldName ? legacySkillPaths(target, oldName) : []) {
645
+ if (!fs.existsSync(fullPath)) continue;
636
646
  fs.rmSync(fullPath, { recursive: true, force: true });
647
+ written.push(fullPath);
637
648
  }
638
649
  }
639
650
 
@@ -679,10 +690,7 @@ function installSkillPack(packName) {
679
690
  }
680
691
  }
681
692
 
682
- function installAddon(addon, roots) {
683
- if (addon.type === 'local') {
684
- return installLocalSkill(addon.name, roots);
685
- }
693
+ function installAddon(addon) {
686
694
  if (addon.type === 'mcp') {
687
695
  return installMcpServer(addon.name, addon.command);
688
696
  }
@@ -691,13 +699,13 @@ function installAddon(addon, roots) {
691
699
 
692
700
  // The core skills into one skill root, replacing older copies and retired skills, then the
693
701
  // marker that records a complete install.
694
- function installCoreSkills(skillsDir) {
702
+ function installCoreSkills(skillsDir, written = [], installedSkills = new Map()) {
695
703
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
696
704
  log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
697
705
  fs.mkdirSync(skillsDir, { recursive: true });
698
706
  const previous = readInstallManifest(skillsDir);
699
707
  clearInstallMarker(skillsDir);
700
- const removed = cleanupDeprecated(skillsDir);
708
+ const removed = cleanupDeprecated(skillsDir, written);
701
709
  if (removed > 0) {
702
710
  log(`\n🧹 Cleaned up ${removed} deprecated file${removed > 1 ? 's' : ''}`, 'yellow');
703
711
  }
@@ -707,11 +715,13 @@ function installCoreSkills(skillsDir) {
707
715
  if (!fs.existsSync(src)) continue;
708
716
  // Full replace: copyRecursive is an overlay, so stale files would otherwise persist.
709
717
  fs.rmSync(dest, { recursive: true, force: true });
710
- copyRecursive(src, dest, skillsDir);
718
+ installedSkills.set(dest, copyRecursive(src, dest, skillsDir));
719
+ written.push(dest);
711
720
  }
712
721
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
713
- const refreshed = retireRenamedSkills(skillsDir);
722
+ const refreshed = retireRenamedSkills(skillsDir, written, installedSkills);
714
723
  writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
724
+ written.push(path.join(skillsDir, DEVLYN_INSTALL_MARKER));
715
725
  }
716
726
 
717
727
  function readProjectGitignore() {
@@ -763,7 +773,9 @@ function updateProjectGitignore(markerEntry) {
763
773
  if (!gitignoreContent.equals(original)) {
764
774
  fs.writeFileSync(gitignorePath, gitignoreContent);
765
775
  log(' → .gitignore (updated project install rules)', 'dim');
776
+ return true;
766
777
  }
778
+ return false;
767
779
  }
768
780
 
769
781
  // With the Claude target in the same run, an AGENTS.md that links to this project's CLAUDE.md (a
@@ -783,17 +795,19 @@ function agentsMdIsClaudeMd() {
783
795
  return target !== null && fs.existsSync(target) && fs.realpathSync.native(target) === fs.realpathSync.native(claude);
784
796
  }
785
797
 
786
- function installAgentsProject(withClaude) {
798
+ function installAgentsProject(withClaude, installedSkills = new Map()) {
799
+ const written = [];
787
800
  if (!agentsMdIsClaudeMd()) {
788
- updateInstructions('AGENTS.md');
801
+ if (updateInstructions('AGENTS.md')) written.push('AGENTS.md');
789
802
  } else if (withClaude) {
790
803
  log(' → AGENTS.md is CLAUDE.md here, so it gets the devlyn block written there', 'dim');
791
804
  } else {
792
805
  throw new InstructionError('AGENTS.md is CLAUDE.md under another name here. Choose CLAUDE.md as well '
793
806
  + '(npx devlyn-cli -y --claude) so the devlyn block is written once, into CLAUDE.md.');
794
807
  }
795
- installCoreSkills(skillRoots('agents', false)[0]);
796
- updateProjectGitignore('.agents/skills/.devlyn-install.json');
808
+ installCoreSkills(skillRoots('agents', false)[0], written, installedSkills);
809
+ if (updateProjectGitignore('.agents/skills/.devlyn-install.json')) written.push('.gitignore');
810
+ return written;
797
811
  }
798
812
 
799
813
  // Whether Claude Code reads the devlyn defaults of AGENTS.md through an `@AGENTS.md` import in this CLAUDE.md text.
@@ -817,12 +831,13 @@ function installClaudeInstructions() {
817
831
  throw new InstructionError('Claude Code reads AGENTS.md here, and its devlyn defaults need an update. Codex, omp, Pi and Grok '
818
832
  + 'read them too, with .agents/skills, so choose AGENTS.md as well (npx devlyn-cli -y --claude) to update both together.');
819
833
  }
820
- updateInstructions('CLAUDE.md', initial, !imported);
834
+ const changed = updateInstructions('CLAUDE.md', initial, !imported);
821
835
  if (imported) log(' → CLAUDE.md imports AGENTS.md, which holds the devlyn block', 'dim');
836
+ return changed;
822
837
  }
823
838
 
824
839
  // Project CLAUDE.md and .claude/: skills and settings.
825
- function installClaudeCore() {
840
+ function installClaudeCore(installedSkills = new Map()) {
826
841
  const skillsDir = skillRoots('claude', false)[0];
827
842
  const targetDir = path.dirname(skillsDir);
828
843
  // Read before any write, so settings this install cannot merge leave the Claude target as it was.
@@ -845,16 +860,17 @@ function installClaudeCore() {
845
860
  && (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env))) {
846
861
  throw unmergeable('env must be a JSON object');
847
862
  }
848
- installClaudeInstructions();
863
+ const written = installClaudeInstructions() ? ['CLAUDE.md'] : [];
849
864
  for (const relPath of DEPRECATED_FILES) {
850
865
  const fullPath = path.join(targetDir, relPath);
851
866
  if (fs.existsSync(fullPath)) {
852
867
  fs.unlinkSync(fullPath);
853
868
  log(` ✕ ${relPath} (deprecated)`, 'dim');
869
+ written.push(`.claude/${relPath}`);
854
870
  }
855
871
  }
856
- installCoreSkills(skillsDir);
857
- updateProjectGitignore('.claude/skills/.devlyn-install.json');
872
+ installCoreSkills(skillsDir, written, installedSkills);
873
+ if (updateProjectGitignore('.claude/skills/.devlyn-install.json')) written.push('.gitignore');
858
874
 
859
875
  // One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
860
876
  let settingsChanged = false;
@@ -885,6 +901,7 @@ function installClaudeCore() {
885
901
  if (settingsChanged) {
886
902
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
887
903
  log(' → settings.json', 'dim');
904
+ written.push('.claude/settings.json');
888
905
  }
889
906
  // Their ownership cannot be established, so the other retired settings stay; name the ones present.
890
907
  const retired = [
@@ -894,6 +911,7 @@ function installClaudeCore() {
894
911
  if (retired.length > 0) {
895
912
  log(` Retired devlyn settings may remain in .claude/settings.json: ${retired.join(', ')} — delete those you did not add yourself.`, 'yellow');
896
913
  }
914
+ return written;
897
915
  }
898
916
 
899
917
  // Installs the targets in one scope; returns the skill roots written.
@@ -908,21 +926,90 @@ function install(targets, global, optionalNames = []) {
908
926
  + 'would apply to every project. Run from a project folder, or use --global for skills only.');
909
927
  }
910
928
  const roots = targets.flatMap((target) => skillRoots(target, global));
929
+ const written = [];
930
+ const installedSkills = new Map();
911
931
  if (!global) readProjectGitignore();
912
932
  preflightSkills(roots, optionalNames,
913
933
  !global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
914
934
  // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
915
935
  for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
916
936
  if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
917
- else if (target === 'agents') installAgentsProject(targets.includes('claude'));
918
- else installClaudeCore();
937
+ else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills));
938
+ else written.push(...installClaudeCore(installedSkills));
919
939
  }
940
+ for (const name of optionalNames) installLocalSkill(name, roots, written, installedSkills);
920
941
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
921
942
  if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
943
+ else noticeProjectChanges(written, installedSkills);
922
944
  noticeGlobalDrift(roots);
923
945
  return roots;
924
946
  }
925
947
 
948
+ // Advisory only: a successful install stays successful when Git is unavailable or fails.
949
+ function noticeProjectChanges(paths, installedSkills = new Map()) {
950
+ if (!paths.length && !installedSkills.size) return;
951
+ const cwd = projectDir();
952
+ const relative = (name) => path.relative(cwd, path.resolve(cwd, name)).split(path.sep).join('/');
953
+ paths = [...new Set(paths.map(relative))];
954
+ const env = { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' };
955
+ const git = (args, input) => spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env, input });
956
+ const repo = git(['rev-parse', '--show-toplevel']);
957
+ if (repo.error || repo.status !== 0) return;
958
+ const status = git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames',
959
+ '--', ...paths.map((name) => `:(icase,literal)${name}`)]);
960
+ if (status.error || status.status !== 0) return;
961
+ // Porcelain paths are repository-relative, even when installing in a nested project.
962
+ const changed = status.stdout.split('\0').filter(Boolean).map((entry) =>
963
+ path.relative(cwd, path.resolve(repo.stdout.replace(/\r?\n$/, ''), entry.slice(3))).split(path.sep).join('/'));
964
+ // Keep the installed folder/file scope, using Git's spelling on case-insensitive volumes.
965
+ const pending = [...new Set(paths.flatMap((name) => [...new Set(changed
966
+ .map((file) => file.split('/').slice(0, name.split('/').length).join('/')))]
967
+ .filter((file) => {
968
+ if (file === name) return true;
969
+ if (file.toLowerCase() !== name.toLowerCase()) return false;
970
+ // Case folding alone can include independently edited files on case-sensitive volumes.
971
+ // Removed paths have no identity left to compare, so require their exact spelling.
972
+ try {
973
+ const written = fs.lstatSync(path.resolve(cwd, name), { bigint: true, throwIfNoEntry: false });
974
+ const reported = fs.lstatSync(path.resolve(cwd, file), { bigint: true, throwIfNoEntry: false });
975
+ return written && reported && written.dev === reported.dev && written.ino === reported.ino;
976
+ } catch (error) {
977
+ log(`Cannot compare Git path ${file} with installed path ${name}: ${error.message}`, 'yellow');
978
+ return false;
979
+ }
980
+ })))];
981
+ // Check only files this run copied, grouped by skill: ignoring a whole skill is deliberate.
982
+ // check-ignore omits tracked files, so a complete committed install stays quiet on reruns.
983
+ const groups = [...installedSkills.values()].map((files) => files.map(relative));
984
+ const files = groups.flat();
985
+ let ignored = [];
986
+ let rule;
987
+ if (files.length) {
988
+ const result = git(['check-ignore', '-z', '--stdin'], files.join('\0') + '\0');
989
+ if (result.error || ![0, 1].includes(result.status)) return;
990
+ const matches = new Set(result.stdout.split('\0').filter(Boolean));
991
+ ignored = [...new Set(groups.flatMap((group) => {
992
+ const hidden = group.filter((file) => matches.has(file));
993
+ return hidden.length === group.length ? [] : hidden;
994
+ }))];
995
+ if (ignored.length) {
996
+ const verbose = git(['check-ignore', '-v', '-z', '--stdin'], ignored[0] + '\0');
997
+ if (verbose.error || verbose.status !== 0) return;
998
+ const [source, line, pattern] = verbose.stdout.split('\0');
999
+ rule = `${source}:${line}: ${pattern}`;
1000
+ }
1001
+ }
1002
+ if (!pending.length && !ignored.length) return;
1003
+ const quote = (name) => /^[a-zA-Z0-9_./-]+$/.test(name) ? name : `'${name.replace(/'/g, "'\\''")}'`;
1004
+ log('Commit these changes for teammates and /devlyn-ideate drains to use them:', 'yellow');
1005
+ if (ignored.length) log(`.gitignore hides ${ignored.length} installed files (${rule}).`, 'yellow');
1006
+ const commands = [];
1007
+ if (pending.length) commands.push(`git add -- ${pending.map(quote).join(' ')}`);
1008
+ if (ignored.length) commands.push(`git add -f -- ${ignored.map(quote).join(' ')}`);
1009
+ commands.push(`git commit -m "chore(devlyn): update devlyn-cli to ${PKG.version}"`);
1010
+ log(` ${commands.join(' && ')}`, 'dim');
1011
+ }
1012
+
926
1013
  // A devlyn install in a user root this run does not refresh drifts and can shadow the
927
1014
  // project's copy; name it without touching it.
928
1015
  function noticeGlobalDrift(installed) {
@@ -982,11 +1069,11 @@ async function init({ yes, claude, global }) {
982
1069
  const scope = await singleSelect('Where', ['This project', 'Global — every project on this machine'], global ? 1 : 0);
983
1070
  // Ask about optional addons (local skills + external packs; MCP servers belong to Claude Code)
984
1071
  const selectedAddons = await multiSelect('Optional skills & packs', OPTIONAL_ADDONS.filter((addon) => addon.type !== 'mcp' || targets.includes('claude')));
985
- const roots = install(targets, scope === 1, selectedAddons.filter((addon) => addon.type === 'local').map((addon) => addon.name));
1072
+ install(targets, scope === 1, selectedAddons.filter((addon) => addon.type === 'local').map((addon) => addon.name));
986
1073
 
987
1074
  if (selectedAddons.length > 0) {
988
- for (const addon of selectedAddons) {
989
- installAddon(addon, roots);
1075
+ for (const addon of selectedAddons.filter((addon) => addon.type !== 'local')) {
1076
+ installAddon(addon);
990
1077
  }
991
1078
  } else {
992
1079
  log('💡 No optional addons selected', 'dim');
@@ -52,15 +52,20 @@ function fingerprint(dir) {
52
52
  return crypto.createHash('sha256').update(JSON.stringify(entries)).digest('hex');
53
53
  }
54
54
 
55
+ function gitEnvironment() {
56
+ const env = { ...process.env, LC_ALL: 'C' };
57
+ for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR',
58
+ 'GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS',
59
+ 'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM']) delete env[key];
60
+ return env;
61
+ }
62
+
55
63
  // Use the index, not status: even files deleted locally need ownership checks.
56
64
  // Inspect the repository containing this root, including global dotfiles repos.
57
65
  function trackedPaths(root) {
58
66
  let cwd = root;
59
67
  while (!fs.existsSync(cwd)) cwd = path.dirname(cwd);
60
- const env = { ...process.env, LC_ALL: 'C' };
61
- for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR',
62
- 'GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS',
63
- 'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM']) delete env[key];
68
+ const env = gitEnvironment();
64
69
  const git = (args) => spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env });
65
70
  const repo = git(['rev-parse', '--show-toplevel']);
66
71
  if (repo.error?.code === 'ENOENT') return [];
@@ -76,4 +81,4 @@ function trackedPaths(root) {
76
81
  return files.stdout.split('\0').filter(Boolean).map((file) => path.resolve(repo.stdout.trim(), file));
77
82
  }
78
83
 
79
- module.exports = { contentHash, fingerprint, isIgnoredEntry, trackedPaths };
84
+ module.exports = { contentHash, fingerprint, gitEnvironment, isIgnoredEntry, trackedPaths };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.7",
3
+ "version": "4.2.9",
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": {