devlyn-cli 4.2.17 → 4.2.19

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
@@ -120,8 +120,9 @@ or delete them; delete `~/.grok/skills`, since Grok now reads `~/.agents/skills`
120
120
  Upgrading to 4.2.0: `/devlyn-resolve` and the pipeline helpers only it used are retired;
121
121
  no measured replacement ships in their place. Run the installer again where devlyn is
122
122
  installed. It removes the `devlyn-resolve` folders (and pre-4.0.0 `devlyn:resolve` ones)
123
- where it installs, removes the Stop hook it added to `.claude/settings.json` while keeping
124
- your own hooks, and names the other settings it once added for the pipeline
123
+ only when they are unchanged devlyn-cli copies; others are left in place and named. It
124
+ removes the Stop hook it added to `.claude/settings.json` while keeping your own hooks,
125
+ and names the other settings it once added for the pipeline
125
126
  (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` and its `.devlyn` and git permissions) without
126
127
  deleting them. `/devlyn-engines` keeps only the executor pin: `pair_judge_priority` and
127
128
  `roles` in `.devlyn/engines.json` stay as written, select nothing, and `clear` removes them.
@@ -147,7 +148,7 @@ devlyn skill by its old name or path (for example Codex `[[skills.config]]` or o
147
148
  `disabledExtensions`) needs the new name. A project that committed the old `devlyn:*`
148
149
  folders checks out on Windows once that re-sync is committed.
149
150
 
150
- Earlier versions of devlyn-cli shipped 16+ slash commands. The iter-0034 Phase 4 cutover (2026-05-04) and the 2026-05-14 follow-up consolidated them, 4.0.0 renamed every devlyn skill to the Agent Skills naming standard (`devlyn-<name>`: lowercase, digits and hyphens, the same as its folder), and after 4.1.0 resolve and design-ui were retired and the queue moved into ideate. Upgrades automatically purge the legacy skill directories.
151
+ Earlier versions of devlyn-cli shipped 16+ slash commands. The iter-0034 Phase 4 cutover (2026-05-04) and the 2026-05-14 follow-up consolidated them, 4.0.0 renamed every devlyn skill to the Agent Skills naming standard (`devlyn-<name>`: lowercase, digits and hyphens, the same as its folder), and after 4.1.0 resolve and design-ui were retired and the queue moved into ideate. Upgrades remove legacy skill directories only when they are unchanged devlyn-cli copies; others are left in place and named.
151
152
 
152
153
  | Old command | Now use |
153
154
  |---|---|
@@ -231,6 +232,8 @@ Only `__pycache__` directories containing exclusively regular `*.pyc` files coun
231
232
 
232
233
  On native Windows, use native Node/npm and Python plus Git for Windows Bash for the shipped shell wrapper. Run `npx devlyn-cli -y` in the project (add `--claude` for Claude Code). Skill folders follow the Agent Skills naming standard (for example `devlyn-ideate`), so a project that commits them checks out on Windows. Harness text is UTF-8 without requiring `PYTHONUTF8`.
233
234
 
235
+ New default drain roots disable Windows content indexing for new children; names stay indexed and Microsoft Defender scanning is unaffected. For Defender performance, use a Dev Drive via `--worktree-root` or an administrator's exclusion for the root; see [loop options](config/skills/devlyn-ideate/references/loop.md).
236
+
234
237
  Windows completion preserves the workspace, owned refs and recovery receipt when writer cessation cannot be proved, even after merge; resume guidance and delivery status remain separate from product verification. The portability workflow checks out the repository on native Windows and tests the POSIX-packed npm artifact there. A POSIX pass alone does not establish Windows support: acceptance requires the passing Windows job for the exact source SHA/artifact hashes.
235
238
 
236
239
  ## Contributing
package/bin/devlyn.js CHANGED
@@ -110,12 +110,13 @@ function hasDevlynInstall(root) {
110
110
  const stat = (name) => fs.lstatSync(name, { throwIfNoEntry: false });
111
111
  if (!stat(root)?.isDirectory()) return false;
112
112
  const optional = new Set(OPTIONAL_ADDONS.map((addon) => addon.name));
113
- const names = [...DEVLYN_CORE_SKILLS, ...DEPRECATED_DIRS.map((name) => path.basename(name)),
113
+ const names = [...DEVLYN_CORE_SKILLS,
114
114
  ...Object.keys(RENAMED_SKILLS)].filter((name) => name.startsWith('devlyn')
115
115
  && !optional.has(name.replace(/[:\uF03A]/g, '-')));
116
116
  return !!stat(path.join(root, DEVLYN_INSTALL_LOCK))
117
117
  || names.some((name) => [...legacySkillPaths(root, name.replace(/^devlyn-/, 'devlyn:')),
118
118
  path.join(root, name.replace(/:/g, '-'))].some((full) => stat(full)?.isDirectory()))
119
+ || planDeprecatedSkills(root, {}).removals.size > 0
119
120
  || (!!stat(path.join(root, DEVLYN_INSTALL_MARKER)) && stat(path.join(root, '_shared'))?.isDirectory());
120
121
  } catch (error) {
121
122
  if (error.code === 'ENOTDIR') return false;
@@ -136,8 +137,15 @@ function hasDevlynClaude(global) {
136
137
  const optional = new Set(OPTIONAL_ADDONS.map((addon) => addon.name));
137
138
  const instructions = path.join(path.dirname(claudeDir), 'CLAUDE.md');
138
139
  const claude = fs.lstatSync(instructions, { throwIfNoEntry: false })?.isFile() ? fs.readFileSync(instructions, 'utf8') : '';
139
- return names(path.join(claudeDir, 'skills')).some((name) => name === DEVLYN_INSTALL_MARKER
140
- || (name.startsWith('devlyn') && !optional.has(name.replace(/[:\uF03A]/g, '-'))))
140
+ const root = path.join(claudeDir, 'skills');
141
+ const deprecated = new Map([...deprecatedSkillPaths(root)].map((full) => [path.basename(full).toLowerCase(), full]));
142
+ const { removals } = planDeprecatedSkills(root, {});
143
+ return names(root).some((name) => {
144
+ const canonical = deprecated.get(name.toLowerCase());
145
+ if (canonical) return removals.has(canonical);
146
+ return name === DEVLYN_INSTALL_MARKER
147
+ || (name.startsWith('devlyn') && !optional.has(name.replace(/[:\uF03A]/g, '-')));
148
+ })
141
149
  || names(path.join(claudeDir, 'commands')).some((name) => name.startsWith('devlyn.'))
142
150
  || holdsDevlynDefaults('CLAUDE.md', claude) || importsAgentsDefaults(claude);
143
151
  }
@@ -368,24 +376,13 @@ function listContents() {
368
376
  }
369
377
 
370
378
  // Old and retired skills in one skill root; nothing outside it.
371
- function cleanupDeprecated(skillsDir, written) {
379
+ function cleanupDeprecated(skillsDir, written, removals) {
372
380
  let removed = 0;
373
- const deprecated = DEPRECATED_DIRS.flatMap((relPath) =>
374
- legacySkillPaths(skillsDir, path.basename(relPath)).map((fullPath) => [relPath, fullPath]));
375
- for (const [relPath, fullPath] of deprecated) {
376
- if (fs.existsSync(fullPath)) {
377
- fs.rmSync(fullPath, { recursive: true });
378
- written.push(fullPath);
379
- log(` ✕ ${relPath}/ (removed)`, 'dim');
380
- removed++;
381
- }
382
- }
383
- for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
384
- const fullPath = path.join(skillsDir, name);
385
- if (isShippedCopy(fullPath, hashes)) {
381
+ for (const fullPath of removals) {
382
+ if (path.dirname(fullPath) === skillsDir) {
386
383
  fs.rmSync(fullPath, { recursive: true });
387
384
  written.push(fullPath);
388
- log(` ✕ skills/${name}/ (removed)`, 'dim');
385
+ log(` ✕ skills/${path.basename(fullPath)}/ (removed)`, 'dim');
389
386
  removed++;
390
387
  }
391
388
  }
@@ -462,24 +459,65 @@ function readInstallManifest(root) {
462
459
  // touching any target. Optional addons must be selected before this preflight too.
463
460
  function plannedSkills(root, optionalNames = []) {
464
461
  const names = new Set([...DEVLYN_CORE_SKILLS, ...optionalNames]);
465
- for (const relative of DEPRECATED_DIRS) {
466
- for (const full of legacySkillPaths(root, path.basename(relative))) names.add(path.basename(full));
467
- }
468
462
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
469
463
  const oldPaths = legacySkillPaths(root, oldName);
470
464
  oldPaths.forEach((full) => names.add(path.basename(full)));
471
465
  if (oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false }))
472
466
  || isShippedCopy(path.join(root, newName), PRE_STANDARD_SKILL_MD_SHA256)) names.add(newName);
473
467
  }
468
+ return names;
469
+ }
470
+
471
+ function verifySkill(dest, name, manifest, removalOnly = false) {
472
+ const actual = fingerprint(dest);
473
+ const logicalName = name.replace(/[:\uF03A]/g, '-');
474
+ const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
475
+ ? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
476
+ const current = !removalOnly && fs.existsSync(source) && fingerprint(source) === actual;
477
+ const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
478
+ const hashes = RETIRED_SKILL_MD_SHA256[name] || (removalOnly ? new Set() : PRE_STANDARD_SKILL_MD_SHA256);
479
+ const singleFile = isShippedCopy(dest, hashes);
480
+ if (!current && !historical && !singleFile && manifest[name] !== actual) {
481
+ throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
482
+ : fs.existsSync(source) || SKILL_HISTORY.skills[logicalName]?.length
483
+ ? 'fingerprint mismatch with known shipped copies'
484
+ : 'unknown installer ownership (no fingerprint history for this copy)');
485
+ }
486
+ }
487
+
488
+ // Removal-only paths never block an install: unverifiable copies stay with their owner.
489
+ function deprecatedSkillPaths(root) {
490
+ return new Set(DEPRECATED_DIRS.flatMap((relative) => legacySkillPaths(root, path.basename(relative))));
491
+ }
492
+
493
+ function planDeprecatedSkills(root, manifest) {
494
+ const removals = new Set();
495
+ const retained = new Set();
496
+ for (const dest of deprecatedSkillPaths(root)) {
497
+ try {
498
+ if (!fs.lstatSync(dest, { throwIfNoEntry: false })) continue;
499
+ verifySkill(dest, path.basename(dest), manifest, true);
500
+ removals.add(dest);
501
+ } catch (error) {
502
+ retained.add(dest);
503
+ }
504
+ }
505
+ return { removals, retained };
506
+ }
507
+
508
+ function planSkillRemovals(root, manifest) {
509
+ const plan = planDeprecatedSkills(root, manifest);
474
510
  for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
475
- if (isShippedCopy(path.join(root, name), hashes)) names.add(name);
511
+ const dest = path.join(root, name);
512
+ if (isShippedCopy(dest, hashes)) plan.removals.add(dest);
476
513
  }
477
- return names;
514
+ return plan;
478
515
  }
479
516
 
480
517
  // `commandDir`: the project .claude/ whose DEPRECATED_FILES this install deletes, or null.
481
518
  function preflightSkills(roots, optionalNames = [], commandDir = null) {
482
519
  const conflicts = [];
520
+ const plan = { removals: new Set(), retained: new Set() };
483
521
  try {
484
522
  // Do not follow a commands link into another installation either.
485
523
  const commands = commandDir && fs.lstatSync(path.join(commandDir, 'commands'), { throwIfNoEntry: false });
@@ -511,6 +549,9 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
511
549
  conflicts.push(`${path.join(root, DEVLYN_INSTALL_MARKER)}: ${error.message}`);
512
550
  continue;
513
551
  }
552
+ const deprecated = planSkillRemovals(root, manifest);
553
+ for (const dest of deprecated.removals) plan.removals.add(dest);
554
+ for (const dest of deprecated.retained) plan.retained.add(dest);
514
555
  for (const name of plannedSkills(root, optionalNames)) {
515
556
  const dest = path.join(root, name);
516
557
  const isTracked = tracked.some((file) => file.toLowerCase() === dest.toLowerCase()
@@ -520,19 +561,7 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
520
561
  if (isTracked) throw new Error('missing contents; cannot verify installer ownership');
521
562
  continue;
522
563
  }
523
- const actual = fingerprint(dest);
524
- const logicalName = name.replace(/[:\uF03A]/g, '-');
525
- const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
526
- ? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
527
- const current = fs.existsSync(source) && fingerprint(source) === actual;
528
- const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
529
- const singleFile = isShippedCopy(dest, RETIRED_SKILL_MD_SHA256[name] || PRE_STANDARD_SKILL_MD_SHA256);
530
- if (!current && !historical && !singleFile && manifest[name] !== actual) {
531
- throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
532
- : fs.existsSync(source) || SKILL_HISTORY.skills[logicalName]?.length
533
- ? 'fingerprint mismatch with known shipped copies'
534
- : 'unknown installer ownership (no fingerprint history for this copy)');
535
- }
564
+ verifySkill(dest, name, manifest);
536
565
  } catch (error) {
537
566
  conflicts.push(`${dest}: ${isTracked ? 'git-tracked; ' : ''}${error.message}`);
538
567
  }
@@ -548,6 +577,7 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
548
577
  + '\nFor tracked paths, also remove them from the Git index.'
549
578
  + '\nRerun the same command after resolving every conflict; -y does not bypass this check.');
550
579
  }
580
+ return plan;
551
581
  }
552
582
 
553
583
  function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
@@ -721,13 +751,14 @@ function installAddon(addon) {
721
751
 
722
752
  // The core skills into one skill root, replacing older copies and retired skills, then the
723
753
  // marker that records a complete install.
724
- function installCoreSkills(skillsDir, written = [], installedSkills = new Map()) {
754
+ function installCoreSkills(skillsDir, written = [], installedSkills = new Map(), plan) {
725
755
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
726
756
  log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
727
757
  fs.mkdirSync(skillsDir, { recursive: true });
728
758
  const previous = readInstallManifest(skillsDir);
759
+ plan ??= planSkillRemovals(skillsDir, previous);
729
760
  clearInstallMarker(skillsDir);
730
- const removed = cleanupDeprecated(skillsDir, written);
761
+ const removed = cleanupDeprecated(skillsDir, written, plan.removals);
731
762
  if (removed > 0) {
732
763
  log(`\n🧹 Cleaned up ${removed} deprecated file${removed > 1 ? 's' : ''}`, 'yellow');
733
764
  }
@@ -817,7 +848,7 @@ function agentsMdIsClaudeMd() {
817
848
  return target !== null && fs.existsSync(target) && fs.realpathSync.native(target) === fs.realpathSync.native(claude);
818
849
  }
819
850
 
820
- function installAgentsProject(withClaude, installedSkills = new Map()) {
851
+ function installAgentsProject(withClaude, installedSkills = new Map(), plan) {
821
852
  const written = [];
822
853
  if (!agentsMdIsClaudeMd()) {
823
854
  if (updateInstructions('AGENTS.md')) written.push('AGENTS.md');
@@ -827,7 +858,7 @@ function installAgentsProject(withClaude, installedSkills = new Map()) {
827
858
  throw new InstructionError('AGENTS.md is CLAUDE.md under another name here. Choose CLAUDE.md as well '
828
859
  + '(npx devlyn-cli -y --claude) so the devlyn block is written once, into CLAUDE.md.');
829
860
  }
830
- installCoreSkills(skillRoots('agents', false)[0], written, installedSkills);
861
+ installCoreSkills(skillRoots('agents', false)[0], written, installedSkills, plan);
831
862
  if (updateProjectGitignore('.agents/skills/.devlyn-install.json')) written.push('.gitignore');
832
863
  return written;
833
864
  }
@@ -859,7 +890,7 @@ function installClaudeInstructions() {
859
890
  }
860
891
 
861
892
  // Project CLAUDE.md and .claude/: skills and settings.
862
- function installClaudeCore(installedSkills = new Map()) {
893
+ function installClaudeCore(installedSkills = new Map(), plan) {
863
894
  const skillsDir = skillRoots('claude', false)[0];
864
895
  const targetDir = path.dirname(skillsDir);
865
896
  // Read before any write, so settings this install cannot merge leave the Claude target as it was.
@@ -891,7 +922,7 @@ function installClaudeCore(installedSkills = new Map()) {
891
922
  written.push(`.claude/${relPath}`);
892
923
  }
893
924
  }
894
- installCoreSkills(skillsDir, written, installedSkills);
925
+ installCoreSkills(skillsDir, written, installedSkills, plan);
895
926
  if (updateProjectGitignore('.claude/skills/.devlyn-install.json')) written.push('.gitignore');
896
927
 
897
928
  // One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
@@ -1007,7 +1038,7 @@ function withUserSkillLocks(roots, action) {
1007
1038
  }
1008
1039
 
1009
1040
  // Installs the targets in one scope; returns the skill roots written.
1010
- function install(targets, global, optionalNames = []) {
1041
+ function install(targets, global, optionalNames = [], plan) {
1011
1042
  // In the home folder CLAUDE.md, AGENTS.md (agents read a parent folder's too) and
1012
1043
  // .claude/settings.json would apply to every project. Same folder by identity, so a link or
1013
1044
  // another spelling of the path is caught too.
@@ -1022,23 +1053,32 @@ function install(targets, global, optionalNames = []) {
1022
1053
  const written = [];
1023
1054
  const installedSkills = new Map();
1024
1055
  if (!global) readProjectGitignore();
1025
- preflightSkills(roots, optionalNames,
1056
+ plan ??= preflightSkills(roots, optionalNames,
1026
1057
  !global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
1027
1058
  // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
1028
1059
  for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
1029
- if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
1030
- else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills));
1031
- else written.push(...installClaudeCore(installedSkills));
1060
+ if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root, [], installedSkills, plan));
1061
+ else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills, plan));
1062
+ else written.push(...installClaudeCore(installedSkills, plan));
1032
1063
  }
1033
1064
  for (const name of optionalNames) installLocalSkill(name, roots, written, installedSkills);
1034
1065
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1035
1066
  if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
1036
1067
  noticeGlobalDrift(roots);
1068
+ noticeRetainedSkills(plan.retained);
1037
1069
  if (!global) noticeProjectChanges(written, installedSkills);
1038
1070
  return roots;
1039
1071
  });
1040
1072
  }
1041
1073
 
1074
+ function noticeRetainedSkills(retained) {
1075
+ if (!retained.size) return;
1076
+ log('Left in place (not verified as unchanged devlyn-cli copies):\n'
1077
+ + [...retained].map((dest) => ` ${dest}`).join('\n')
1078
+ + '\nThese retired skills may still be visible to agents and may call removed helpers. '
1079
+ + 'Move them out of the skills folder, or delete them, if you no longer use them.', 'yellow');
1080
+ }
1081
+
1042
1082
  // Advisory only: a successful install stays successful when Git is unavailable or fails.
1043
1083
  function noticeProjectChanges(paths, installedSkills = new Map()) {
1044
1084
  if (!paths.length && !installedSkills.size) return;
@@ -1095,7 +1135,7 @@ function noticeProjectChanges(paths, installedSkills = new Map()) {
1095
1135
  }
1096
1136
  if (!pending.length && !ignored.length) return;
1097
1137
  const quote = (name) => /^[a-zA-Z0-9_./-]+$/.test(name) ? name : `'${name.replace(/'/g, "'\\''")}'`;
1098
- log('Commit these changes for teammates and /devlyn-ideate drains to use them:', 'yellow');
1138
+ log('Optional — commit (and push) these changes if teammates or /devlyn-ideate task worktrees should use them:', 'yellow');
1099
1139
  if (ignored.length) log(`.gitignore hides ${ignored.length} installed files (${rule}).`, 'yellow');
1100
1140
  const commands = [];
1101
1141
  if (pending.length) commands.push(`git add -- ${pending.map(quote).join(' ')}`);
@@ -1167,11 +1207,8 @@ function update() {
1167
1207
  }
1168
1208
 
1169
1209
  return withUserSkillLocks(userRoots, () => {
1170
- if (targets.length) {
1171
- readProjectGitignore();
1172
- preflightSkills(projectRoots, [], targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
1173
- }
1174
- preflightSkills(userRoots);
1210
+ if (targets.length) readProjectGitignore();
1211
+ const plan = preflightSkills(roots, [], targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
1175
1212
  // Ownership preflight has already refused unreadable or invalid markers.
1176
1213
  const current = PKG.version.split('.').map(BigInt);
1177
1214
  const newer = roots.flatMap((root) => {
@@ -1194,11 +1231,12 @@ function update() {
1194
1231
  + newer.join('\n') + '\nRun npx devlyn-cli@latest update.');
1195
1232
  }
1196
1233
 
1197
- userRoots.forEach((root) => installCoreSkills(root));
1198
- if (targets.length) install(targets, false);
1234
+ userRoots.forEach((root) => installCoreSkills(root, [], new Map(), plan));
1235
+ if (targets.length) install(targets, false, [], plan);
1199
1236
  else {
1200
1237
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1201
1238
  noticeGlobalDrift(userRoots);
1239
+ noticeRetainedSkills(plan.retained);
1202
1240
  }
1203
1241
  });
1204
1242
  }
@@ -111,6 +111,7 @@
111
111
  "a9c5242608e8b6f660f3ae9daf3fdefb210b6e4603bddee3d26ba59a55be4085",
112
112
  "aa0d6fad740ead835720a528f197b29e6cd0b209d3b9db1b312b9895f991b037",
113
113
  "ac3d475156af89e2e887118e92b972dbfefde6f0a07bbbdb68ba88fce5f7904d",
114
+ "ac404e4e846557b4c7b32050dee5f7c83648311b7d1cecdbdcae9eeda1b8eaf7",
114
115
  "ac5485ee9218c7038c6d329a3877925995c5afcb2dbd16b1da4495ed769d2083",
115
116
  "add41e1257ed525d6f225d9eb976a3978f126e604ca7c0f5aa1b223af78f1902",
116
117
  "afb478a981c4fd91f66fbe10d04481cd4562825fd6785ba0aadc6ce3ee3da173",
@@ -322,6 +323,7 @@
322
323
  "5954e371659ae1010757f0eec4b80dd65a899f9fcf76d4d84d9a75476a963218",
323
324
  "65770e3f20c3a3afd2822e9acdcfc80b827f84a7737010b79332da70d431b05d",
324
325
  "65c781764633b912e5947362eba0837c8027461a904975401d79ad4fe8714084",
326
+ "694c8920e50d92cba46dbb27eb44dd88035a89ed625d3cbf9f2bce9366d44c96",
325
327
  "6c3e2e4b84b0b4f81aed3759704da7c14f3cbabf3beeb0d1654feda3a15a8675",
326
328
  "6d9df58c117165e9d297aa6be0df8d076bac1ed49dc29ae93906af1969798ab1",
327
329
  "6e7228610628fc4bca31dc0b0aaf5eb4445e1f123f2c54bd13921c5cd01129fe",
@@ -115,6 +115,15 @@ def system_exit_code(code):
115
115
  return code - 0x100000000 if os.name == "nt" and code >= 0x80000000 else code
116
116
 
117
117
 
118
+ def mark_not_content_indexed(path):
119
+ """Disable Windows content indexing for a directory and children created afterwards."""
120
+ if os.name != "nt":
121
+ return
122
+ attrs = _kernel.GetFileAttributesW(str(path))
123
+ _checked(attrs != 0xFFFFFFFF, _kernel.GetFileAttributesW, (str(path),)) # INVALID_FILE_ATTRIBUTES.
124
+ _kernel.SetFileAttributesW(str(path), attrs | stat.FILE_ATTRIBUTE_NOT_CONTENT_INDEXED)
125
+
126
+
118
127
  if os.name == "nt":
119
128
  import _winapi
120
129
  import ctypes
@@ -164,6 +173,7 @@ if os.name == "nt":
164
173
  ("DuplicateHandle", [wintypes.HANDLE, wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.HANDLE), wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.BOOL),
165
174
  ("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
166
175
  ("PeekNamedPipe", [wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p, ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p], wintypes.BOOL),
176
+ ("SetFileAttributesW", [wintypes.LPCWSTR, wintypes.DWORD], wintypes.BOOL),
167
177
  ):
168
178
  _function = getattr(_kernel, _name)
169
179
  _function.argtypes, _function.restype, _function.errcheck = _args, _result, _checked
@@ -171,6 +181,9 @@ if os.name == "nt":
171
181
  _kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
172
182
  _kernel.WaitForSingleObject.restype = wintypes.DWORD # WAIT_OBJECT_0 is successful zero, not BOOL.
173
183
 
184
+ _kernel.GetFileAttributesW.argtypes = [wintypes.LPCWSTR]
185
+ _kernel.GetFileAttributesW.restype = wintypes.DWORD # Failure is INVALID_FILE_ATTRIBUTES, not zero.
186
+
174
187
  def _read_control(fd, count):
175
188
  deadline = time.monotonic() + 10
176
189
  data = b""
@@ -52,10 +52,12 @@ not write to the shared checkout. Queue drains retain their allocation order.
52
52
  ```sh
53
53
  python3 "$DEVLYN_SHARED_DIR/task-complete.py" allocate --repo . \
54
54
  --task '<task identity>' --branch '<absent task branch>' \
55
- --worktree '<absent path>' --repository '<owner/repo>' --remote origin --base main
55
+ --worktree '<repo parent>/<repo name>.devlyn.noindex/<task directory>' \
56
+ --repository '<owner/repo>' --remote origin --base main
56
57
  ```
57
58
 
58
- Every delivery allocation owns a linked worktree; `--worktree` is required.
59
+ Every delivery allocation owns a linked worktree; `--worktree` is required and
60
+ must name an absent path.
59
61
  A publication allocation normally starts at the exact fetched remote base,
60
62
  independent of the anchor's branch or dirty state, and leaves the anchor's
61
63
  HEAD, index and files untouched. For a commit request, local-only work or
@@ -14,7 +14,7 @@ Both print one JSON object; exit 1 is `BLOCKED` with a `reason`.
14
14
  - `status` takes `queue.lock` in the repository's Git directory and finishes `add`'s cleanup (step 1); it reports the running `skill` root and version, any `warnings`, reconciled counts (`pending`, `active`, `accepted`, `failed`, `blocked`, `legacy_pending`), the `next` task, `blockers`, deliveries still pending with their resume (drain again until the terminal commit is attached, then the printed completion command; add `--writers-stopped` only after stopping the task's writers), and under `cleanup` the package copies `add` kept or could not remove.
15
15
  - `drain` ends `DRAINED` (no runnable task remains; open deliveries show per task), `WAITING` (a task waits with its reason, or legacy rows need planning) or `BLOCKED` (unreadable or ambiguous authoritative queue, invalid invocation, preflight refusal or held drain lock). Progress lines `devlyn-loop: <task>: <event>` go to stderr.
16
16
  - `--local-only` (alias `--no-push`) keeps the loop local. A manifest `delivery: local-only` and any earlier local receipt of the loop keep that restriction for later drains. A local loop needs no remote; nothing is fetched or pushed. An `auto`/`pr` loop none of whose tasks has a pushed PR runs as a local loop from its capture and the branch it was added on. Once one has, that PR is never rewritten to local and the loop is never run locally: its tasks wait, naming the PR, while other work continues.
17
- - Worktrees default to `<repo parent>/<repo name>.devlyn/<loop-id>/<task-id>`.
17
+ - Worktrees default to `<repo parent>/<repo name>.devlyn.noindex/<loop-id>/<task-id>`; tasks already allocated under an earlier default resume where their receipts record them. On Windows, a default root newly created by the drain gets the not-content-indexed attribute, inherited by new children (content only; names stay indexed). Microsoft Defender scanning is unaffected; use a Dev Drive via `--worktree-root` or an administrator's Defender exclusion for the root.
18
18
 
19
19
  The host supplies the executor and maps its configured engine to the argv; the loop has no model preference. Each `{packet}` in the argv is replaced by the task packet path, and each `{worktree_git_dir}` by the task worktree's absolute Git directory. The executor runs with the task worktree as its working directory and the null device as stdin; its stdout and stderr are appended to `executor.stdout` and `executor.stderr` in the packet's `evidence_dir`, never a pipe.
20
20
 
@@ -1062,6 +1062,17 @@ def allocate(v, row, opts):
1062
1062
  if values["start"] and (drift := instruction_drift(anchor, start)):
1063
1063
  return (f"the installed instructions ({drift}) in this checkout differ from those in its start commit {start}, so it would run "
1064
1064
  f"without them; commit them and push them to origin/{manifest['base_ref']}")
1065
+ if os.name == "nt" and opts.default_worktree_root:
1066
+ try:
1067
+ opts.worktree_root.mkdir(exist_ok=False)
1068
+ except FileExistsError:
1069
+ pass # An existing root belongs to its owner; never change its attributes.
1070
+ else:
1071
+ try:
1072
+ shared("platform-support")["mark_not_content_indexed"](opts.worktree_root)
1073
+ except OSError as exc:
1074
+ opts.root_diagnostics.append(
1075
+ f"content indexing unchanged: {opts.worktree_root}: could not set FILE_ATTRIBUTE_NOT_CONTENT_INDEXED: {exc}; continuing")
1065
1076
  result = task_complete("allocate", exclude_receipts=v["invalid_receipts"], **values)
1066
1077
  progress(identity, f"allocated {result['worktree']}")
1067
1078
  return None
@@ -1426,7 +1437,9 @@ def drain(args):
1426
1437
  args.repo = anchor # Preserve the repository root for failures reported by main.
1427
1438
  diagnostics = skill_diagnostics(anchor)
1428
1439
  require(any("{packet}" in part for part in args.executor), "executor argv must contain {packet}, replaced by the task packet path")
1429
- args.worktree_root = Path(args.worktree_root).resolve() if args.worktree_root else anchor.parent / f"{anchor.name}.devlyn"
1440
+ args.default_worktree_root = args.worktree_root is None
1441
+ args.root_diagnostics = []
1442
+ args.worktree_root = Path(args.worktree_root).resolve() if args.worktree_root else anchor.parent / f"{anchor.name}.devlyn.noindex"
1430
1443
  with lock(common, "drain.lock", blocking=False):
1431
1444
  attempted, last, status, reason, refused = set(), None, "DRAINED", None, {}
1432
1445
  try:
@@ -1455,6 +1468,7 @@ def drain(args):
1455
1468
  last = view(anchor, common, args.local_only)
1456
1469
  if last is None:
1457
1470
  return {"status": status, "reason": reason, **diagnostics}
1471
+ last["cleanup"].extend(args.root_diagnostics)
1458
1472
  annotate(last, refused)
1459
1473
  prune_parents(last, args.worktree_root)
1460
1474
  return {"status": status, "reason": reason, **diagnostics,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.17",
3
+ "version": "4.2.19",
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": {