devlyn-cli 4.2.20 → 4.2.22

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, 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
  }
@@ -100,6 +100,7 @@
100
100
  "978e4210e49b42827c98cf6e40c868c87dc6e3533a2b779ca2fa05abfc16ad0f",
101
101
  "9815b6e6963e068872a6dbeae24068143bf92042bb035f67bafd1a224d65bcb0",
102
102
  "998343c148618b937556b58b83749c05d5aa3283280d751c118da6a542fb87f2",
103
+ "9dd3ec225818efe0ad3726192db94ff129154e995617a9d3c150fc58ad0f9f2c",
103
104
  "9f14ca629e41730b1a9fb5d0ad1a39cff558fe34758d6ceccbb9de498f9f384e",
104
105
  "a11950a5880346f03257c688fd8bc6f8c29fc0b7c05c90e8f5211d5fb7665774",
105
106
  "a405a99b988f6edd2eabb0504b36309bb4cf0c3b97eab26bb7ef257e4515dbf3",
@@ -21,6 +21,7 @@ import stat
21
21
  import subprocess
22
22
  import sys
23
23
  import tempfile
24
+ import time
24
25
  import unittest
25
26
  import uuid
26
27
 
@@ -789,11 +790,35 @@ def complete(args):
789
790
  require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
790
791
  refused = None
791
792
  if not pr.get("autoMergeRequest"):
792
- try:
793
- require(info["mergeCommitAllowed"], "repository disallows merge commits")
794
- gh(receipt, "pr", "merge", str(pr["number"]), "--auto", "--merge", "--match-head-commit", receipt["publish_sha"])
795
- except CompletionError as error:
796
- refused = str(error)
793
+ if not info["mergeCommitAllowed"]:
794
+ refused = "repository disallows merge commits"
795
+ else:
796
+ owner, name = receipt["repository"].split("/")
797
+ auto_allowed = json.loads(command([
798
+ "gh", "api", "graphql", "--hostname", "github.com", "-f",
799
+ "query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){autoMergeAllowed}}",
800
+ "-f", "owner="+owner, "-f", "name="+name,
801
+ "--jq", ".data.repository.autoMergeAllowed | tojson",
802
+ ]))
803
+ require(isinstance(auto_allowed, bool), "repository autoMergeAllowed must be a JSON boolean")
804
+ auto = ["--auto"] if auto_allowed else []
805
+ if not auto:
806
+ # UNKNOWN can reach gh's direct merge mutation; wait for a
807
+ # resolved state and permit only gh's immediate-merge set.
808
+ for attempt in range(10):
809
+ state = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", "mergeStateStatus"))["mergeStateStatus"]
810
+ require(isinstance(state, str) and state, "invalid mergeStateStatus")
811
+ if state != "UNKNOWN":
812
+ break
813
+ if attempt < 9:
814
+ time.sleep(2)
815
+ if state not in {"CLEAN", "HAS_HOOKS", "UNSTABLE"}:
816
+ refused = "pull request is not mergeable: " + state
817
+ if not refused:
818
+ try:
819
+ gh(receipt, "pr", "merge", str(pr["number"]), *auto, "--merge", "--match-head-commit", receipt["publish_sha"])
820
+ except CompletionError as error:
821
+ refused = str(error)
797
822
  pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
798
823
  validate_pr(receipt, pr)
799
824
  if pr["state"] != "MERGED":
@@ -885,6 +910,16 @@ def remote(ref):
885
910
  if a[:2] == ['repo', 'view']:
886
911
  assert a[2:] == ['github.com/test/project', '--json', 'nameWithOwner,url,defaultBranchRef,mergeCommitAllowed'], 'unsupported gh repo view arguments: '+str(a)
887
912
  print(json.dumps({'nameWithOwner':'test/project', 'url':'https://github.com/test/project', 'defaultBranchRef':{'name':d.get('default_branch','main')}, 'mergeCommitAllowed':d.get('merge_allowed',True)}))
913
+ elif a[:1] == ['api']:
914
+ assert a == ['api', 'graphql', '--hostname', 'github.com', '-f',
915
+ 'query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){autoMergeAllowed}}',
916
+ '-f', 'owner=test', '-f', 'name=project',
917
+ '--jq', '.data.repository.autoMergeAllowed | tojson'], 'unsupported gh api arguments: '+str(a)
918
+ d['auto_setting_reads'] = d.get('auto_setting_reads',0)+1
919
+ save()
920
+ if d.get('auto_setting_error'):
921
+ print('auto-merge setting read failed',file=sys.stderr); sys.exit(1)
922
+ print(d.get('auto_setting_output', json.dumps(d.get('auto_allowed',True))))
888
923
  elif a[:2] == ['pr', 'list']:
889
924
  assert '--repo' in a and a[a.index('--repo')+1] == 'github.com/test/project'
890
925
  print(json.dumps([pr for pr in prs if pr['headRefName'] == a[a.index('--head')+1]]))
@@ -898,21 +933,42 @@ elif a[:2] == ['pr', 'create']:
898
933
  print(url)
899
934
  if d.pop('interrupt_create',False): save(); sys.exit(1)
900
935
  elif a[:2] == ['pr', 'view']:
901
- print(json.dumps(prs[int(a[2])-1]))
936
+ pr = prs[int(a[2])-1]
937
+ fields = a[4]
938
+ assert a[3:] == ['--json', fields, '--repo', 'github.com/test/project']
939
+ assert fields in ('mergeStateStatus', 'number,url,headRefName,baseRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,state,mergedAt,mergeCommit,autoMergeRequest')
940
+ if fields == 'mergeStateStatus':
941
+ if d.get('merge_state_error'):
942
+ print('merge state read failed',file=sys.stderr); sys.exit(1)
943
+ if d.get('merge_states'):
944
+ d['merge_state'] = d['merge_states'].pop(0)
945
+ state = d.get('merge_state', 'BLOCKED' if d.get('pending') else 'CLEAN')
946
+ d.setdefault('merge_state_views', []).append(state)
947
+ save()
948
+ print(json.dumps({'mergeStateStatus': state}))
949
+ else:
950
+ print(json.dumps(pr))
902
951
  elif a[:2] == ['pr', 'merge'] and '--disable-auto' in a:
903
952
  d['disables'] = d.get('disables',0)+1
904
953
  prs[int(a[2])-1]['autoMergeRequest'] = None
905
954
  save()
906
955
  elif a[:2] == ['pr', 'merge']:
907
- assert '--admin' not in a and '--delete-branch' not in a
908
- assert '--auto' in a and '--merge' in a and '--match-head-commit' in a
909
956
  pr = prs[int(a[2])-1]
910
957
  sha = a[a.index('--match-head-commit')+1]
958
+ auto = ['--auto'] if '--auto' in a else []
959
+ assert a[3:] == auto + ['--merge', '--match-head-commit', sha, '--repo', 'github.com/test/project']
911
960
  assert sha == remote('refs/heads/'+pr['headRefName'])
912
961
  d['merges'] = d.get('merges',0)+1
913
- if not d.get('auto_allowed',True) and d.get('pending'):
962
+ d.setdefault('merge_calls', []).append(a)
963
+ state = d.get('merge_state', 'BLOCKED' if d.get('pending') else 'CLEAN')
964
+ immediately_mergeable = state in {'CLEAN', 'HAS_HOOKS', 'UNSTABLE'}
965
+ if auto and not immediately_mergeable and not d.get('auto_allowed',True):
914
966
  save(); print('repository disallows auto merge',file=sys.stderr); sys.exit(1)
915
- if d.get('pending'):
967
+ if d.get('merge_error'):
968
+ save(); print('merge attempt refused',file=sys.stderr); sys.exit(1)
969
+ if not auto and not immediately_mergeable and state != 'UNKNOWN':
970
+ save(); print('pull request is not mergeable: '+state,file=sys.stderr); sys.exit(1)
971
+ if auto and not immediately_mergeable:
916
972
  pr['autoMergeRequest'] = {'enabledAt':'now'}
917
973
  else:
918
974
  g = [os.environ['REAL_GIT'],'--git-dir',d['bare']]
@@ -1042,7 +1098,7 @@ class CompletionTests(unittest.TestCase):
1042
1098
 
1043
1099
  def merge_pr(self):
1044
1100
  self.configure(pending=False)
1045
- self.run_cmd(["gh", "pr", "merge", "1", "--auto", "--merge", "--match-head-commit", self.sha])
1101
+ self.run_cmd(["gh", "pr", "merge", "1", "--auto", "--merge", "--match-head-commit", self.sha, "--repo", "github.com/test/project"])
1046
1102
 
1047
1103
  def anchor_state(self, work=None):
1048
1104
  work = work or self.work
@@ -2119,6 +2175,127 @@ ctypes.CDLL(None).pthread_exit(None)
2119
2175
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], race)
2120
2176
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
2121
2177
 
2178
+ def test_auto_disallowed_unknown_then_clean_merges_directly(self):
2179
+ self.allocate(); self.accept()
2180
+ self.configure(auto_allowed=False, merge_states=["UNKNOWN", "UNKNOWN", "CLEAN"])
2181
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
2182
+ self.assertEqual(result["status"], "COMPLETE")
2183
+ d = json.loads(self.data.read_text(encoding="utf-8"))
2184
+ self.assertEqual(d["merge_state_views"], ["UNKNOWN", "UNKNOWN", "CLEAN"])
2185
+ self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
2186
+ "--repo", "github.com/test/project"]])
2187
+ self.assertEqual(d["prs"][0]["state"], "MERGED")
2188
+ self.assertIsNone(d["prs"][0]["autoMergeRequest"])
2189
+
2190
+ def test_auto_disallowed_blocked_refuses_without_merge(self):
2191
+ self.allocate(); self.accept()
2192
+ for state in ("BLOCKED", "BEHIND", "DIRTY", "DRAFT", "NEW_STATE"):
2193
+ with self.subTest(state=state):
2194
+ self.configure(auto_allowed=False, merge_state=state)
2195
+ result, _ = self.complete("--mode", "auto")
2196
+ self.assertEqual(result["status"], "PR")
2197
+ self.assertIn("not mergeable: " + state, result["merge_refused"])
2198
+ d = json.loads(self.data.read_text(encoding="utf-8"))
2199
+ self.assertEqual(d["merge_state_views"], ["BLOCKED", "BEHIND", "DIRTY", "DRAFT", "NEW_STATE"])
2200
+ self.assertEqual(d.get("merge_calls", []), [])
2201
+ self.assertEqual(d["prs"][0]["state"], "OPEN")
2202
+ self.assertIsNone(d["prs"][0]["autoMergeRequest"])
2203
+ self.assertTrue(self.task.exists())
2204
+
2205
+ def test_auto_allowed_unknown_keeps_auto_pending(self):
2206
+ self.allocate(); self.accept()
2207
+ self.configure(auto_allowed=True, merge_state="UNKNOWN")
2208
+ result, _ = self.complete("--mode", "auto")
2209
+ self.assertEqual(result["status"], "PENDING")
2210
+ self.assertNotIn("merge_refused", result)
2211
+ d = json.loads(self.data.read_text(encoding="utf-8"))
2212
+ self.assertEqual(d.get("merge_state_views", []), [])
2213
+ self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--auto", "--merge", "--match-head-commit", self.sha,
2214
+ "--repo", "github.com/test/project"]])
2215
+ self.assertEqual(d["prs"][0]["state"], "OPEN")
2216
+ self.assertTrue(d["prs"][0]["autoMergeRequest"])
2217
+ self.assertTrue(self.task.exists())
2218
+ self.configure(auto_setting_error=True)
2219
+ result, _ = self.complete()
2220
+ self.assertEqual(result["status"], "PENDING")
2221
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["auto_setting_reads"], 1)
2222
+
2223
+ def test_auto_disallowed_unknown_bound_refuses_without_merge(self):
2224
+ self.allocate(); self.accept()
2225
+ self.configure(auto_allowed=False, merge_state="UNKNOWN")
2226
+ result, _ = self.complete("--mode", "auto")
2227
+ self.assertEqual(result["status"], "PR")
2228
+ self.assertIn("not mergeable: UNKNOWN", result["merge_refused"])
2229
+ d = json.loads(self.data.read_text(encoding="utf-8"))
2230
+ self.assertEqual(d["merge_state_views"], ["UNKNOWN"] * 10)
2231
+ self.assertEqual(d.get("merge_calls", []), [])
2232
+ self.assertEqual(d["prs"][0]["state"], "OPEN")
2233
+ self.assertIsNone(d["prs"][0]["autoMergeRequest"])
2234
+ self.assertTrue(self.task.exists())
2235
+
2236
+ def test_auto_disallowed_other_immediate_states_attempt_direct_merge(self):
2237
+ self.allocate(); self.accept()
2238
+ for state in ("HAS_HOOKS", "UNSTABLE"):
2239
+ with self.subTest(state=state):
2240
+ self.configure(auto_allowed=False, merge_state=state, merge_error=True, merge_calls=[])
2241
+ result, _ = self.complete("--mode", "auto")
2242
+ self.assertEqual(result["status"], "PR")
2243
+ self.assertIn("merge attempt refused", result["merge_refused"])
2244
+ d = json.loads(self.data.read_text(encoding="utf-8"))
2245
+ self.assertEqual(d["merge_calls"], [["pr", "merge", "1", "--merge",
2246
+ "--match-head-commit", self.sha, "--repo", "github.com/test/project"]])
2247
+
2248
+ def test_auto_setting_read_failure_or_invalid_value_blocks_with_resume(self):
2249
+ self.allocate(); self.accept()
2250
+ for values in ({"auto_setting_error": True}, *({"auto_setting_error": False, "auto_setting_output": output}
2251
+ for output in ('null', '"true"', '"false"', '0', '1', '{}', '[]', '', 'invalid'))):
2252
+ with self.subTest(values=values):
2253
+ self.configure(**values)
2254
+ result, process = self.complete("--mode", "auto", "--writers-stopped", success=False)
2255
+ self.assertNotEqual(process.returncode, 0)
2256
+ self.assertEqual(result["status"], "BLOCKED")
2257
+ if values.get("auto_setting_output") == 'null':
2258
+ self.assertEqual(result["reason"], "repository autoMergeAllowed must be a JSON boolean")
2259
+ self.assertIn(str(self.receipt), result["resume"])
2260
+ self.assertNotIn("merge_refused", result)
2261
+ d = json.loads(self.data.read_text(encoding="utf-8"))
2262
+ self.assertEqual(d.get("merge_calls", []), [])
2263
+ self.assertEqual(d["prs"][0]["state"], "OPEN")
2264
+ self.configure(auto_setting_output='false')
2265
+ result, _ = self.cli(*shlex.split(result["resume"])[2:])
2266
+ self.assertEqual(result["status"], "COMPLETE")
2267
+
2268
+ def test_auto_disallowed_status_poll_failure_blocks_with_resume(self):
2269
+ self.allocate(); self.accept()
2270
+ self.configure(auto_allowed=False, merge_state_error=True)
2271
+ result, process = self.complete("--mode", "auto", "--writers-stopped", success=False)
2272
+ self.assertNotEqual(process.returncode, 0)
2273
+ self.assertEqual(result["status"], "BLOCKED")
2274
+ self.assertIn("merge state read failed", result["reason"])
2275
+ self.assertIn(str(self.receipt), result["resume"])
2276
+ self.assertNotIn("merge_refused", result)
2277
+ d = json.loads(self.data.read_text(encoding="utf-8"))
2278
+ self.assertEqual(d.get("merge_calls", []), [])
2279
+ self.assertEqual(d["prs"][0]["state"], "OPEN")
2280
+ self.configure(merge_state_error=False)
2281
+ result, _ = self.cli(*shlex.split(result["resume"])[2:])
2282
+ self.assertEqual(result["status"], "COMPLETE")
2283
+
2284
+ def test_merge_attempt_refusal_keeps_pr(self):
2285
+ self.allocate(); self.accept()
2286
+ self.configure(auto_allowed=False, merge_error=True)
2287
+ result, _ = self.complete("--mode", "auto")
2288
+ self.assertEqual(result["status"], "PR")
2289
+ self.assertIn("merge attempt refused", result["merge_refused"])
2290
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["merges"], 1)
2291
+
2292
+ def test_pr_mode_does_not_read_auto_setting(self):
2293
+ self.allocate(); self.accept()
2294
+ self.configure(auto_setting_error=True)
2295
+ result, _ = self.complete("--mode", "pr")
2296
+ self.assertEqual(result["status"], "PR")
2297
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("auto_setting_reads", 0), 0)
2298
+
2122
2299
  def test_policy_and_local_only_persist(self):
2123
2300
  self.allocate(); self.accept()
2124
2301
  self.configure(merge_allowed=False)
@@ -2133,7 +2310,7 @@ ctypes.CDLL(None).pthread_exit(None)
2133
2310
  self.configure(merge_allowed=True, auto_allowed=False, pending=True)
2134
2311
  result, _ = self.complete("--mode", "auto")
2135
2312
  self.assertEqual(result["status"], "PR")
2136
- self.assertIn("disallows auto merge", result["merge_refused"])
2313
+ self.assertIn("not mergeable: BLOCKED", result["merge_refused"])
2137
2314
  self.assertTrue(self.task.exists())
2138
2315
  # Local-only cannot rewrite the delivery of a pushed PR; a local allocation keeps it (test_local_chain_*).
2139
2316
  result, _ = self.complete("--no-push", success=False)
@@ -182,7 +182,7 @@ the user's delivery restriction. These per-task choices persist in the receipt;
182
182
  head and base PR. While OPEN, it cancels owned auto-merge, reports `PR` with its URL
183
183
  and retains the checkout. Once MERGED by any method, either mode cleans released
184
184
  resources in that call, on resume or at the next allocation. Default `auto` also requests
185
- `gh pr merge --auto --merge --match-head-commit <accepted SHA>`. Repository
185
+ a `--merge --match-head-commit <accepted SHA>` merge. Repository
186
186
  checks/reviews and merge policy remain authoritative: no admin bypass, strategy
187
187
  fallback or repository setting changes. When the repository disallows merge
188
188
  commits or refuses the request, delivery reports `PR` with `merge_refused` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.20",
3
+ "version": "4.2.22",
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": {