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 +2 -2
- package/bin/devlyn.js +50 -111
- package/bin/skill-history.json +1 -0
- package/config/skills/_shared/task-complete.py +189 -12
- package/config/skills/_shared/task-completion.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ That's it. The installer asks:
|
|
|
35
35
|
|
|
36
36
|
In a project, AGENTS.md readers load the skills from `.agents/skills/`; Claude Code loads only `.claude/skills/` and reads AGENTS.md when the project has no CLAUDE.md; a CLAUDE.md the installer creates imports an existing AGENTS.md (`@AGENTS.md`). Global installs skills only: `~/.agents/skills/` (omp, Pi, Grok) and `~/.codex/skills/` (Codex), plus `~/.claude/skills/` for Claude Code. Every target gets the `devlyn-ideate` skill and the `devlyn-engines` utility. In Codex / omp / Pi, invoke them as skills (`$devlyn-ideate`); in Claude Code and Grok they're slash commands (`/devlyn-ideate`). Rerunning refreshes skills and the managed instruction block while preserving project rules outside it. See [Migration from earlier versions](#migration-from-earlier-versions) for legacy migration and merge recovery.
|
|
37
37
|
|
|
38
|
-
Without prompts, `npx devlyn-cli -y` installs AGENTS.md + `.agents/skills/` plus every target this project already has; add `--claude` for Claude Code. With `--global` it installs for every project on this machine, plus `~/.claude/skills/` with `--claude` or when it already has a devlyn install.
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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],
|
|
799
|
-
|
|
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
|
-
|
|
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
|
-
|
|
856
|
-
installCoreSkills(skillsDir,
|
|
857
|
-
|
|
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
|
|
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,
|
|
1014
|
-
else if (target === 'agents')
|
|
1015
|
-
else
|
|
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,
|
|
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,
|
|
1009
|
+
cleanupScope(global ? os.homedir() : projectDir(), global,
|
|
1023
1010
|
global ? [] : targets.map((target) => target === 'agents' ? 'AGENTS.md' : 'CLAUDE.md'));
|
|
1024
|
-
if (!global)
|
|
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,
|
|
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
|
-
|
|
1131
|
+
updateInstructions(name, '', false, true, base, evaluatorOnly);
|
|
1148
1132
|
});
|
|
1149
1133
|
}
|
|
1150
1134
|
return found;
|
|
1151
1135
|
}
|
|
1152
1136
|
|
|
1153
|
-
// Advisory only:
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
|
1158
|
-
|
|
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
|
|
1162
|
-
if (
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
|
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
|
|
1277
|
-
const
|
|
1278
|
-
|
|
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
|
|
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
|
-
|
|
1317
|
-
cleanupScope(projectDir(), false, written);
|
|
1318
|
-
noticeProjectChanges(written);
|
|
1257
|
+
cleanupScope(projectDir(), false);
|
|
1319
1258
|
}
|
|
1320
1259
|
});
|
|
1321
1260
|
}
|
package/bin/skill-history.json
CHANGED
|
@@ -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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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("
|
|
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
|
-
|
|
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.
|
|
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": {
|