devlyn-cli 4.2.8 → 4.2.10

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. After a project install or update, the installer names the changed files to commit 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. 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
@@ -374,17 +374,18 @@ function cleanupDeprecated(skillsDir, written) {
374
374
  // are in place. An optional skill found under an old spelling is installed under its new name
375
375
  // before the old copy goes, so an interrupted migration never loses it. An unedited 0.6.0-0.7.1
376
376
  // copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
377
- function retireRenamedSkills(skillsDir, written) {
377
+ function retireRenamedSkills(skillsDir, written, installedSkills) {
378
378
  const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
379
379
  const refreshed = [];
380
380
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
381
381
  const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
382
382
  if (found.length > 0 && optional.has(newName)) {
383
- installOptionalSkillInto(skillsDir, newName, written);
383
+ installOptionalSkillInto(skillsDir, newName, written, installedSkills);
384
384
  refreshed.push(newName);
385
385
  } else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
386
386
  refreshPreStandardCopy(skillsDir, newName);
387
387
  written.push(path.join(skillsDir, newName));
388
+ installedSkills.set(path.join(skillsDir, newName), [path.join(skillsDir, newName, 'SKILL.md')]);
388
389
  refreshed.push(newName);
389
390
  }
390
391
  for (const fullPath of found) {
@@ -396,7 +397,7 @@ function retireRenamedSkills(skillsDir, written) {
396
397
  return refreshed;
397
398
  }
398
399
 
399
- function copyRecursive(src, dest, baseDir) {
400
+ function copyRecursive(src, dest, baseDir, files = []) {
400
401
  const stats = fs.statSync(src);
401
402
 
402
403
  if (stats.isDirectory()) {
@@ -405,7 +406,7 @@ function copyRecursive(src, dest, baseDir) {
405
406
  }
406
407
 
407
408
  for (const item of fs.readdirSync(src)) {
408
- copyRecursive(path.join(src, item), path.join(dest, item), baseDir);
409
+ copyRecursive(path.join(src, item), path.join(dest, item), baseDir, files);
409
410
  }
410
411
  } else {
411
412
  const destDir = path.dirname(dest);
@@ -413,8 +414,10 @@ function copyRecursive(src, dest, baseDir) {
413
414
  fs.mkdirSync(destDir, { recursive: true });
414
415
  }
415
416
  fs.copyFileSync(src, dest);
417
+ files.push(dest);
416
418
  log(` → ${path.relative(baseDir, dest)}`, 'dim');
417
419
  }
420
+ return files;
418
421
  }
419
422
 
420
423
  function clearInstallMarker(skillsDir) {
@@ -615,14 +618,14 @@ function singleSelect(message, items, initial) {
615
618
  }
616
619
 
617
620
  // After preflight has cleared every selected addon before any change.
618
- function installLocalSkill(skillName, roots, written = []) {
621
+ function installLocalSkill(skillName, roots, written = [], installedSkills = new Map()) {
619
622
  if (!fs.existsSync(path.join(OPTIONAL_SKILLS_SOURCE, skillName))) {
620
623
  log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
621
624
  return false;
622
625
  }
623
626
  log(`\n🛠️ Installing ${skillName}...`, 'cyan');
624
627
  for (const root of roots) {
625
- installOptionalSkillInto(root, skillName, written);
628
+ installOptionalSkillInto(root, skillName, written, installedSkills);
626
629
  writeInstallMarker(root, [skillName], readInstallManifest(root));
627
630
  written.push(path.join(root, DEVLYN_INSTALL_MARKER));
628
631
  }
@@ -631,10 +634,10 @@ function installLocalSkill(skillName, roots, written = []) {
631
634
 
632
635
  // One optional skill into one skill-loader directory, replacing any older copy of it. A
633
636
  // copy under its name before the 4.0.0 rename goes only once the new one is complete.
634
- function installOptionalSkillInto(target, skillName, written) {
637
+ function installOptionalSkillInto(target, skillName, written, installedSkills) {
635
638
  const dest = path.join(target, skillName);
636
639
  fs.rmSync(dest, { recursive: true, force: true });
637
- copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), dest, target);
640
+ installedSkills.set(dest, copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), dest, target));
638
641
  assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, target, [skillName]);
639
642
  written.push(dest);
640
643
  const oldName = Object.keys(RENAMED_SKILLS).find((name) => RENAMED_SKILLS[name] === skillName);
@@ -696,7 +699,7 @@ function installAddon(addon) {
696
699
 
697
700
  // The core skills into one skill root, replacing older copies and retired skills, then the
698
701
  // marker that records a complete install.
699
- function installCoreSkills(skillsDir, written = []) {
702
+ function installCoreSkills(skillsDir, written = [], installedSkills = new Map()) {
700
703
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
701
704
  log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
702
705
  fs.mkdirSync(skillsDir, { recursive: true });
@@ -712,11 +715,11 @@ function installCoreSkills(skillsDir, written = []) {
712
715
  if (!fs.existsSync(src)) continue;
713
716
  // Full replace: copyRecursive is an overlay, so stale files would otherwise persist.
714
717
  fs.rmSync(dest, { recursive: true, force: true });
715
- copyRecursive(src, dest, skillsDir);
718
+ installedSkills.set(dest, copyRecursive(src, dest, skillsDir));
716
719
  written.push(dest);
717
720
  }
718
721
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
719
- const refreshed = retireRenamedSkills(skillsDir, written);
722
+ const refreshed = retireRenamedSkills(skillsDir, written, installedSkills);
720
723
  writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
721
724
  written.push(path.join(skillsDir, DEVLYN_INSTALL_MARKER));
722
725
  }
@@ -792,7 +795,7 @@ function agentsMdIsClaudeMd() {
792
795
  return target !== null && fs.existsSync(target) && fs.realpathSync.native(target) === fs.realpathSync.native(claude);
793
796
  }
794
797
 
795
- function installAgentsProject(withClaude) {
798
+ function installAgentsProject(withClaude, installedSkills = new Map()) {
796
799
  const written = [];
797
800
  if (!agentsMdIsClaudeMd()) {
798
801
  if (updateInstructions('AGENTS.md')) written.push('AGENTS.md');
@@ -802,7 +805,7 @@ function installAgentsProject(withClaude) {
802
805
  throw new InstructionError('AGENTS.md is CLAUDE.md under another name here. Choose CLAUDE.md as well '
803
806
  + '(npx devlyn-cli -y --claude) so the devlyn block is written once, into CLAUDE.md.');
804
807
  }
805
- installCoreSkills(skillRoots('agents', false)[0], written);
808
+ installCoreSkills(skillRoots('agents', false)[0], written, installedSkills);
806
809
  if (updateProjectGitignore('.agents/skills/.devlyn-install.json')) written.push('.gitignore');
807
810
  return written;
808
811
  }
@@ -834,7 +837,7 @@ function installClaudeInstructions() {
834
837
  }
835
838
 
836
839
  // Project CLAUDE.md and .claude/: skills and settings.
837
- function installClaudeCore() {
840
+ function installClaudeCore(installedSkills = new Map()) {
838
841
  const skillsDir = skillRoots('claude', false)[0];
839
842
  const targetDir = path.dirname(skillsDir);
840
843
  // Read before any write, so settings this install cannot merge leave the Claude target as it was.
@@ -866,7 +869,7 @@ function installClaudeCore() {
866
869
  written.push(`.claude/${relPath}`);
867
870
  }
868
871
  }
869
- installCoreSkills(skillsDir, written);
872
+ installCoreSkills(skillsDir, written, installedSkills);
870
873
  if (updateProjectGitignore('.claude/skills/.devlyn-install.json')) written.push('.gitignore');
871
874
 
872
875
  // One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
@@ -924,30 +927,32 @@ function install(targets, global, optionalNames = []) {
924
927
  }
925
928
  const roots = targets.flatMap((target) => skillRoots(target, global));
926
929
  const written = [];
930
+ const installedSkills = new Map();
927
931
  if (!global) readProjectGitignore();
928
932
  preflightSkills(roots, optionalNames,
929
933
  !global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
930
934
  // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
931
935
  for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
932
936
  if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
933
- else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude')));
934
- else written.push(...installClaudeCore());
937
+ else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills));
938
+ else written.push(...installClaudeCore(installedSkills));
935
939
  }
936
- for (const name of optionalNames) installLocalSkill(name, roots, written);
940
+ for (const name of optionalNames) installLocalSkill(name, roots, written, installedSkills);
937
941
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
938
942
  if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
939
- else noticeProjectChanges(written);
943
+ else noticeProjectChanges(written, installedSkills);
940
944
  noticeGlobalDrift(roots);
941
945
  return roots;
942
946
  }
943
947
 
944
948
  // Advisory only: a successful install stays successful when Git is unavailable or fails.
945
- function noticeProjectChanges(paths) {
946
- if (!paths.length) return;
949
+ function noticeProjectChanges(paths, installedSkills = new Map()) {
950
+ if (!paths.length && !installedSkills.size) return;
947
951
  const cwd = projectDir();
948
- paths = [...new Set(paths.map((name) => path.relative(cwd, path.resolve(cwd, name)).split(path.sep).join('/')))];
952
+ const relative = (name) => path.relative(cwd, path.resolve(cwd, name)).split(path.sep).join('/');
953
+ paths = [...new Set(paths.map(relative))];
949
954
  const env = { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' };
950
- const git = (args) => spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env });
955
+ const git = (args, input) => spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env, input });
951
956
  const repo = git(['rev-parse', '--show-toplevel']);
952
957
  if (repo.error || repo.status !== 0) return;
953
958
  const status = git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames',
@@ -973,10 +978,36 @@ function noticeProjectChanges(paths) {
973
978
  return false;
974
979
  }
975
980
  })))];
976
- if (!pending.length) return;
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;
977
1003
  const quote = (name) => /^[a-zA-Z0-9_./-]+$/.test(name) ? name : `'${name.replace(/'/g, "'\\''")}'`;
978
1004
  log('Commit these changes for teammates and /devlyn-ideate drains to use them:', 'yellow');
979
- log(` git add -- ${pending.map(quote).join(' ')} && git commit -m "chore(devlyn): update devlyn-cli to ${PKG.version}"`, 'dim');
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');
980
1011
  }
981
1012
 
982
1013
  // A devlyn install in a user root this run does not refresh drifts and can shadow the
@@ -312,6 +312,7 @@
312
312
  "36b58dbdc26e51abc05a60872c4582faeebd67e9fe5626a3933467ba07c68dc4",
313
313
  "40237cd5259dbcf7c490f130e844a9e32eea48a8edabd50800edee5029bf95a4",
314
314
  "5536f8391ec4442f1b793dba8dd5d7820d45f4b6dadf1111cbac9d54469a01ca",
315
+ "573a6059a7383b37affd9c2e8bc6c88a4fb54117628a5a0746406cc32111e9aa",
315
316
  "5824f07e455fe1c3942bf595a12e935258d4c0a851ca1bf741db915603d1c4e0",
316
317
  "5954e371659ae1010757f0eec4b80dd65a899f9fcf76d4d84d9a75476a963218",
317
318
  "65770e3f20c3a3afd2822e9acdcfc80b827f84a7737010b79332da70d431b05d",
@@ -486,19 +486,26 @@ def queue_view(anchor, recorded):
486
486
  tracked.update(rel for rel in git(anchor, "ls-tree", "-r", "-z", "--name-only", head, "--", "docs/specs").split("\0")
487
487
  if re.fullmatch(r"docs/specs/[^/]+/queue\.md", rel))
488
488
  for rel in sorted(tracked, key=lambda rel: rel.split("/")[2]):
489
- loop, data = rel.split("/")[2], (anchor / rel).read_bytes() if (anchor / rel).is_file() else None
490
- if loop not in recorded:
491
- # A directory name alone proves nothing. A task row in the checkout or HEAD proves this is a loop queue;
492
- # HEAD keeps that proof when the checkout loses its rows. Strict decoding/parsing follows only that proof.
493
- known = False
494
- for candidate in (data, show(anchor, head, rel) if head else None):
495
- for line in (candidate or b"").decode("utf-8", errors="replace").splitlines():
496
- match = ROW_RE.fullmatch(line)
497
- item = ITEM_RE.fullmatch(match["text"].rstrip()) if match else None
498
- if item and item["loop"] == loop and bool(item["rest"]) == (match["mark"] == "F"):
499
- known = True
500
- if known:
501
- loops[loop] = parse_loop(data, loop, anchor / rel)
489
+ loop, data, read_error = rel.split("/")[2], None, None
490
+ if loop in recorded:
491
+ continue
492
+ try:
493
+ data = (anchor / rel).read_bytes() if (anchor / rel).is_file() else None
494
+ except OSError as exc:
495
+ read_error = exc
496
+ # A directory name alone proves nothing. A task row in the checkout or HEAD proves this is a loop queue; HEAD
497
+ # keeps that proof when the checkout loses its rows or cannot be read. Only a proven queue is parsed or blocks.
498
+ known = False
499
+ for candidate in (data, show(anchor, head, rel) if head else None):
500
+ for line in (candidate or b"").decode("utf-8", errors="replace").splitlines():
501
+ match = ROW_RE.fullmatch(line)
502
+ item = ITEM_RE.fullmatch(match["text"].rstrip()) if match else None
503
+ if item and item["loop"] == loop and bool(item["rest"]) == (match["mark"] == "F"):
504
+ known = True
505
+ if known:
506
+ if read_error is not None:
507
+ raise LoopError(f"{anchor / rel}: {read_error}") from read_error
508
+ loops[loop] = parse_loop(data, loop, anchor / rel)
502
509
  claims = {}
503
510
  for loop, (_, target) in loops.items():
504
511
  if target:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.8",
3
+ "version": "4.2.10",
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": {