devlyn-cli 4.3.28 → 4.3.29

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.
Files changed (64) hide show
  1. package/README.md +10 -10
  2. package/bin/devlyn.js +92 -58
  3. package/bin/engines.js +5 -3
  4. package/bin/harness-activation.js +208 -11
  5. package/bin/skill-history.json +9 -0
  6. package/config/skills/_shared/engine-doctor.sh +2 -2
  7. package/config/skills/_shared/engine-preflight.md +2 -2
  8. package/config/skills/_shared/managed-storage.md +80 -0
  9. package/config/skills/_shared/platform-support.py +67 -28
  10. package/config/skills/_shared/role-config.py +16 -7
  11. package/config/skills/_shared/storage-client.py +31 -7
  12. package/config/skills/_shared/task-complete.py +1 -1
  13. package/config/skills/_shared/task-completion.md +2 -76
  14. package/config/skills/devlyn-engines/SKILL.md +1 -1
  15. package/config/skills/devlyn-ideate/SKILL.md +2 -2
  16. package/config/skills/devlyn-ideate/references/executor.md +62 -0
  17. package/config/skills/devlyn-ideate/references/loop.md +2 -59
  18. package/config/skills/devlyn-ideate/references/package-format.md +1 -1
  19. package/config/skills/devlyn-ideate/scripts/acceptance.py +442 -21
  20. package/config/skills/devlyn-ideate/scripts/queue.py +58 -10
  21. package/native/claude/.claude-plugin/plugin.json +1 -1
  22. package/native/claude/skills/_shared/engine-doctor.sh +2 -2
  23. package/native/claude/skills/_shared/engine-preflight.md +2 -2
  24. package/native/claude/skills/_shared/managed-storage.md +80 -0
  25. package/native/claude/skills/_shared/platform-support.py +67 -28
  26. package/native/claude/skills/_shared/role-config.py +16 -7
  27. package/native/claude/skills/_shared/storage-client.py +31 -7
  28. package/native/claude/skills/_shared/task-complete.py +1 -1
  29. package/native/claude/skills/_shared/task-completion.md +2 -76
  30. package/native/claude/skills/devlyn-engines/SKILL.md +1 -1
  31. package/native/claude/skills/devlyn-ideate/SKILL.md +2 -2
  32. package/native/claude/skills/devlyn-ideate/references/executor.md +62 -0
  33. package/native/claude/skills/devlyn-ideate/references/loop.md +2 -59
  34. package/native/claude/skills/devlyn-ideate/references/package-format.md +1 -1
  35. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +442 -21
  36. package/native/claude/skills/devlyn-ideate/scripts/queue.py +58 -10
  37. package/native/codex/.agents/plugins/marketplace.json +1 -1
  38. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  39. package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +2 -2
  40. package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +2 -2
  41. package/native/codex/plugins/devlyn/skills/_shared/managed-storage.md +80 -0
  42. package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +67 -28
  43. package/native/codex/plugins/devlyn/skills/_shared/role-config.py +16 -7
  44. package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +31 -7
  45. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +1 -1
  46. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +2 -76
  47. package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +1 -1
  48. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +2 -2
  49. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/executor.md +62 -0
  50. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +2 -59
  51. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +1 -1
  52. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +442 -21
  53. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +58 -10
  54. package/package.json +1 -11
  55. package/release.json +89 -59
  56. /package/config/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
  57. /package/config/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
  58. /package/config/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
  59. /package/native/claude/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
  60. /package/native/claude/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
  61. /package/native/claude/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
  62. /package/native/codex/plugins/devlyn/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
  63. /package/native/codex/plugins/devlyn/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
  64. /package/native/codex/plugins/devlyn/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
package/README.md CHANGED
@@ -43,17 +43,17 @@ Without prompts, `npx devlyn-cli -y` installs `.agents/skills/` plus every targe
43
43
 
44
44
  ## How It Works — Principles and Loops
45
45
 
46
- The Devlyn defaults carry the North Star, seven principles (no workaround, no overengineering, no guesswork, worldclass, best practice, optimized, production ready) and three discipline rules, plus short pointers to the skills. Each session gets them from the release, through the Devlyn app or `devlyn run`, so projects hold no copy and a new release reaches every project at once. A clone without either gets no Devlyn defaults. Agents do the work directly under them; for work that splits into tasks, ideate designs a loop and drains it:
46
+ The Devlyn defaults carry the North Star, seven principles (no workaround, no overengineering, no guesswork, worldclass, best practice, optimized, production ready) and three discipline rules, plus short pointers to the skills. Each session gets them from the release, through the Devlyn app or `devlyn run`, so projects hold no copy and a new release reaches every project at once. A session started any other way, as with a plain `npx devlyn-cli` install, gets the skills but not the defaults; ideate still reads its installed copy for the loops it plans and drains. Agents do the work directly under them; for work that splits into tasks, ideate designs a loop and drains it:
47
47
 
48
48
  ```
49
- intent → direct work under the installed principles, or an ideate loop → ship
49
+ intent → direct work under the Devlyn defaults, or an ideate loop → ship
50
50
  ```
51
51
 
52
- Non-Claude agents (Codex / omp / Pi / Grok): the default choice installs the skills for them. In Codex / omp / Pi, use `$devlyn-ideate`; in Grok, use `/devlyn-ideate`, the same slash-command form as Claude Code. A drain from Pi or Grok needs [a pinned executor](#executor--devlyn-engines).
52
+ Non-Claude agents (Codex / omp / Pi / Grok): the default choice installs the skills for them. In Codex / omp / Pi, use `$devlyn-ideate`; in Grok, use `/devlyn-ideate`, the same slash-command form as Claude Code. A drain from any CLI other than Claude Code or Codex needs [a pinned executor](#executor--devlyn-engines).
53
53
 
54
54
  ### Terminal sessions — `devlyn run`
55
55
 
56
- `devlyn run claude`, `devlyn run codex` or `devlyn run omp` starts the engine in this terminal with the same principles and skills the Devlyn app delivers, from the selected release. Arguments after the engine name go to the engine. See [docs/harness-run.md](docs/harness-run.md).
56
+ `devlyn run claude`, `devlyn run codex` or `devlyn run omp` starts the engine in this terminal on macOS or Linux (not Windows) with the same principles and skills the Devlyn app delivers, from the selected release. While the harness is on for the project, only engine arguments known not to change the session's freshness, directory or planned settings pass through, and any other stops the launch; with it off, every argument passes. See [docs/harness-run.md](docs/harness-run.md).
57
57
 
58
58
  ### Plan and drain loops — `/devlyn-ideate`
59
59
 
@@ -66,7 +66,7 @@ Give ideate an intent or a document. It writes a loop package — a meta-prompt
66
66
  | `/devlyn-ideate status` | Pending, active, accepted and failed counts; the next runnable task; delivery and recovery blockers. |
67
67
  | `/devlyn-ideate drain` | Runs the queue serially and hands-free. |
68
68
 
69
- 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 the Devlyn defaults and your project's CLAUDE.md/AGENTS.md. 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 brings the result into your branch, stopping if your files cannot be preserved.
69
+ 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; a CLI other than Claude Code or Codex needs a pin) under the Devlyn defaults and your project's CLAUDE.md/AGENTS.md. 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 CLAUDE.md/AGENTS.md changes 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 brings the result into your branch, stopping if your files cannot be preserved.
70
70
 
71
71
  ### Delivery
72
72
 
@@ -86,9 +86,9 @@ for allocation, acceptance, writer cessation and recovery.
86
86
 
87
87
  ### Executor — `/devlyn-engines`
88
88
 
89
- The orchestrator is the CLI you opened (Claude Code, Codex, omp, Pi or Grok); the contract is symmetric (`CLAUDE.md` ↔ `AGENTS.md`), so the loop's file artifacts carry over if you switch. The executor does the implementation work, direct or drained: the engine pinned in machine-local `.devlyn/engines.json`, else the CLI you opened. Pi and Grok have no executor adapter, so a drain from them needs `devlyn-engines executor <claude|codex|omp>`.
89
+ The orchestrator is the CLI you opened (Claude Code, Codex, omp, Pi or Grok); the contract is symmetric (`CLAUDE.md` ↔ `AGENTS.md`), so the loop's file artifacts carry over if you switch. The executor does the implementation work, direct or drained: the engine pinned in machine-local `.devlyn/engines.json`, else the CLI you opened. Only Claude and Codex can execute, so a drain from any other CLI needs `devlyn-engines executor <claude|codex>`.
90
90
 
91
- `/devlyn-engines` with no arguments shows the executor and the engines detected on this machine; `executor <name>` pins one and `clear` removes the pin. A pin is a promise: an unavailable pinned engine stops dispatch with `BLOCKED:<engine>-unavailable`, and a name with no executor-eligible `_shared/adapters/<name>.md` stops with `BLOCKED:invalid-engine-config`. Adapters ship with devlyn-cli releases; a reinstall replaces `_shared`.
91
+ `/devlyn-engines` with no arguments shows the executor and the engines detected on this machine; `executor <name>` pins one and `clear` removes the pin. A pin is a promise: an unavailable pinned engine stops dispatch with `BLOCKED:<engine>-unavailable`, and a name with no executor-eligible `_shared/adapters/<name>.adapter.md` stops with `BLOCKED:invalid-engine-config`. Adapters ship with devlyn-cli releases; a reinstall replaces `_shared`.
92
92
 
93
93
  Unattended children disable owner-desktop and account drivers: every monitored
94
94
  Codex child and every `claude` drain executor (including `.exe`/`.cmd`/`.bat`)
@@ -171,11 +171,11 @@ Earlier versions of devlyn-cli shipped 16+ slash commands. The iter-0034 Phase 4
171
171
 
172
172
  | Old command | Now use |
173
173
  |---|---|
174
- | `/devlyn:auto-resolve`, `/devlyn:preflight`, `/devlyn:evaluate`, `/devlyn:review`, `/devlyn:team-resolve`, `/devlyn:team-review`, `/devlyn:clean`, `/devlyn:update-docs`, `/devlyn:browser-validate`, `/devlyn:implement-ui` | Folded into resolve, now retired — work directly under the installed principles |
174
+ | `/devlyn:auto-resolve`, `/devlyn:preflight`, `/devlyn:evaluate`, `/devlyn:review`, `/devlyn:team-resolve`, `/devlyn:team-review`, `/devlyn:clean`, `/devlyn:update-docs`, `/devlyn:browser-validate`, `/devlyn:implement-ui` | Folded into resolve, now retired — work directly under the Devlyn defaults |
175
175
  | `/devlyn:product-spec`, `/devlyn:feature-spec`, `/devlyn:recommend-features`, `/devlyn:discover-product` | `/devlyn-ideate` |
176
176
  | `/devlyn:design-system` | Removed 2026-05-14 — no replacement |
177
177
  | `/devlyn:ideate`, `/devlyn:engines`, `devlyn:pencil-pull`, `devlyn:pencil-push`, `devlyn:reap` (3.x and earlier) | `/devlyn-ideate`, `/devlyn-engines`, `devlyn-pencil-pull`, `devlyn-pencil-push`, `devlyn-reap` (4.0.0) |
178
- | `/devlyn:resolve` (3.x and earlier), `/devlyn-resolve` (4.0.0–4.1.0) | Retired — work directly under the installed principles; `/devlyn-ideate` plans and drains multi-task work |
178
+ | `/devlyn:resolve` (3.x and earlier), `/devlyn-resolve` (4.0.0–4.1.0) | Retired — work directly under the Devlyn defaults; `/devlyn-ideate` plans and drains multi-task work |
179
179
  | `/devlyn:queue` (3.x and earlier), `/devlyn-queue` (4.0.0–4.1.0) | `/devlyn-ideate add`, `status` and `drain` |
180
180
  | `/devlyn-ideate --quick`, `--from-spec`, `--project` (4.1.0 and earlier) | `/devlyn-ideate plan`; `--autonomous` replaces `--quick` |
181
181
  | `/devlyn:team-design-ui`, `/devlyn:design-ui` (3.x and earlier), `/devlyn-design-ui` (4.0.0–4.1.0) | Retired — no replacement |
@@ -257,7 +257,7 @@ Windows completion preserves the workspace, owned refs and recovery receipt when
257
257
 
258
258
  ## Contributing
259
259
 
260
- - **Add a skill** — add it to `optional-skills/` and `OPTIONAL_ADDONS` in [`bin/devlyn.js`](bin/devlyn.js); the core skills are fixed by `DEVLYN_CORE_SKILLS` (lint Checks 5a and 6)
260
+ - **Add a skill** — add it to `optional-skills/` and `OPTIONAL_ADDONS` in [`bin/devlyn.js`](bin/devlyn.js); the core skills are fixed by `DEVLYN_CORE_SKILLS` (lint Check 5a)
261
261
  - **Suggest a pack** — PR to the pack list
262
262
 
263
263
  ## Supercharge it — pair devlyn with persistent agent memory
package/bin/devlyn.js CHANGED
@@ -358,7 +358,7 @@ function refreshRenamedSkills(skillsDir, installedSkills) {
358
358
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
359
359
  const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
360
360
  if (found.length > 0 && optional.has(newName) && canRefreshRenamed(skillsDir, newName)) {
361
- installOptionalSkillInto(skillsDir, newName, installedSkills);
361
+ installSkillInto(OPTIONAL_SKILLS_SOURCE, skillsDir, newName, installedSkills);
362
362
  refreshed.push(newName);
363
363
  } else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
364
364
  refreshPreStandardCopy(skillsDir, newName);
@@ -610,21 +610,22 @@ function installLocalSkill(skillName, roots, installedSkills = new Map()) {
610
610
  }
611
611
  log(`\n🛠️ Installing ${skillName}...`, 'cyan');
612
612
  for (const root of roots) {
613
- installOptionalSkillInto(root, skillName, installedSkills);
613
+ installSkillInto(OPTIONAL_SKILLS_SOURCE, root, skillName, installedSkills);
614
614
  writeInstallMarker(root, [skillName], readInstallManifest(root));
615
615
  }
616
616
  return true;
617
617
  }
618
618
 
619
- // One optional skill into one skill-loader directory, replacing any older copy of it. A
620
- // copy under its name before the 4.0.0 rename goes only once the new one is complete.
621
- function installOptionalSkillInto(target, skillName, installedSkills) {
619
+ // One skill from `source` into one skill-loader directory. The copy is staged and checked first, so
620
+ // a failed copy leaves any older copy of it in place for a plain rerun. A copy under its name
621
+ // before the 4.0.0 rename goes only once the new one is complete.
622
+ function installSkillInto(source, target, skillName, installedSkills) {
622
623
  const dest = path.join(target, skillName);
623
624
  const stage = fs.mkdtempSync(path.join(target, '.devlyn-skill-'));
624
625
  const stagedSkill = path.join(stage, skillName);
625
626
  try {
626
- const files = copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), stagedSkill, stage);
627
- assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, stage, [skillName]);
627
+ const files = copyRecursive(path.join(source, skillName), stagedSkill, stage);
628
+ assertCompleteSkillInstall(source, stage, [skillName]);
628
629
  fs.rmSync(dest, { recursive: true, force: true });
629
630
  fs.renameSync(stagedSkill, dest);
630
631
  installedSkills.set(dest, files.map((file) => path.join(dest, path.relative(stagedSkill, file))));
@@ -692,12 +693,7 @@ function installCoreSkills(skillsDir, installedSkills = new Map()) {
692
693
  const previous = readInstallManifest(skillsDir);
693
694
  clearInstallMarker(skillsDir);
694
695
  for (const skillName of DEVLYN_CORE_SKILLS) {
695
- const src = path.join(sourceSkillsDir, skillName);
696
- const dest = path.join(skillsDir, skillName);
697
- if (!fs.existsSync(src)) continue;
698
- // Full replace: copyRecursive is an overlay, so stale files would otherwise persist.
699
- fs.rmSync(dest, { recursive: true, force: true });
700
- installedSkills.set(dest, copyRecursive(src, dest, skillsDir));
696
+ if (fs.existsSync(path.join(sourceSkillsDir, skillName))) installSkillInto(sourceSkillsDir, skillsDir, skillName, installedSkills);
701
697
  }
702
698
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
703
699
  const refreshed = refreshRenamedSkills(skillsDir, installedSkills);
@@ -773,14 +769,14 @@ function installClaudeCore(installedSkills = new Map()) {
773
769
  + '.claude/ is unchanged; fix the file and rerun the same install command.');
774
770
  const settingsStat = fs.lstatSync(settingsPath, { throwIfNoEntry: false });
775
771
  if (settingsStat && !settingsStat.isFile()) throw unmergeable('not a regular file');
776
- let settings = {};
777
- if (fs.existsSync(settingsPath)) {
778
- try {
779
- // Claude Code accepts a leading byte order mark.
780
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''));
781
- } catch (error) {
782
- throw unmergeable(error.message);
783
- }
772
+ let text = '{}\n'; // A missing file starts as an empty object.
773
+ let settings;
774
+ try {
775
+ // Claude Code accepts a leading byte order mark; bytes that are not UTF-8 could not be written back as they are.
776
+ if (settingsStat) text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(settingsPath));
777
+ settings = JSON.parse(text.replace(/^\uFEFF/, ''));
778
+ } catch (error) {
779
+ throw unmergeable(error.message);
784
780
  }
785
781
  if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
786
782
  throw unmergeable('root must be a JSON object');
@@ -789,31 +785,24 @@ function installClaudeCore(installedSkills = new Map()) {
789
785
  && (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env))) {
790
786
  throw unmergeable('env must be a JSON object');
791
787
  }
788
+ // The env edit finds the first env while JSON.parse keeps the last, so a repeated env has no single place to edit.
789
+ if (parseTree(text.replace(/^\uFEFF/, ' ')).children.filter((property) => property.children[0].value === 'env').length > 1) {
790
+ throw unmergeable('env appears more than once');
791
+ }
792
792
  installCoreSkills(skillsDir, installedSkills);
793
793
  updateProjectGitignore('.claude/skills/.devlyn-install.json');
794
794
 
795
795
  // One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
796
- let settingsChanged = false;
797
- if (!Object.prototype.hasOwnProperty.call(settings, 'env')) {
798
- settings.env = {};
799
- settingsChanged = true;
800
- }
801
- if (!Object.prototype.hasOwnProperty.call(settings.env, 'ENABLE_PROMPT_CACHING_1H')) {
802
- settings.env.ENABLE_PROMPT_CACHING_1H = 'true';
803
- settingsChanged = true;
804
- }
805
- if (!Object.prototype.hasOwnProperty.call(settings.env, 'BASH_MAX_TIMEOUT_MS')) {
806
- settings.env.BASH_MAX_TIMEOUT_MS = '3600000';
807
- settingsChanged = true;
808
- }
809
- settingsChanged = cleanRetiredSettings(settings) || settingsChanged;
810
- if (settingsChanged) {
811
- replaceFile(settingsPath, JSON.stringify(settings, null, 2) + '\n');
796
+ const env = settings.env || {};
797
+ const missing = Object.entries({ ENABLE_PROMPT_CACHING_1H: 'true', BASH_MAX_TIMEOUT_MS: '3600000' })
798
+ .filter(([key]) => !Object.prototype.hasOwnProperty.call(env, key));
799
+ if (missing.length) {
800
+ replaceFile(settingsPath, insertSettingsEnv(text, missing));
812
801
  log(' → settings.json', 'dim');
813
802
  }
814
803
  // Their ownership cannot be established, so the other retired settings stay; name the ones present.
815
804
  const retired = [
816
- ...RETIRED_ENV.filter((key) => Object.prototype.hasOwnProperty.call(settings.env, key)),
805
+ ...RETIRED_ENV.filter((key) => Object.prototype.hasOwnProperty.call(env, key)),
817
806
  ...RETIRED_PERMISSIONS.filter((rule) => Array.isArray(settings.permissions?.allow) && settings.permissions.allow.includes(rule)),
818
807
  ];
819
808
  if (retired.length > 0) {
@@ -833,7 +822,40 @@ function replaceFile(file, text) {
833
822
  }
834
823
  }
835
824
 
836
- function cleanRetiredSettings(settings, hookScripts = []) {
825
+ // Adds the env entries as new text laid out like the members they join, so every existing byte stays: the byte
826
+ // order mark, line endings, indentation, escapes and numbers beyond double precision. jsonc-parser's
827
+ // formattingOptions would lay them out too, but by reformatting the neighbouring lines.
828
+ function insertSettingsEnv(text, entries) {
829
+ const tree = () => parseTree(text.replace(/^\uFEFF/, ' '));
830
+ // The whitespace an object puts after its opening brace, before a later member and before its closing brace,
831
+ // and around its colons.
832
+ const layout = (object) => {
833
+ const first = object.children[0], last = object.children.at(-1), [key, value] = last.children;
834
+ const before = text.slice(0, last.offset);
835
+ return { open: text.slice(object.offset + 1, first.offset), lead: before.slice(before.trimEnd().length),
836
+ close: text.slice(last.offset + last.length, object.offset + object.length - 1),
837
+ colon: text.slice(key.offset + key.length, value.offset) };
838
+ };
839
+ const insert = (object, members, { open, lead, close }) => {
840
+ const last = object.children.at(-1);
841
+ text = applyEdits(text, [last
842
+ ? { offset: last.offset + last.length, length: 0, content: members.map((member) => `,${lead}${member}`).join('') }
843
+ : { offset: object.offset + 1, length: 0, content: open + members.join(`,${lead}`) + (object.length === 2 ? close : '') }]);
844
+ };
845
+ const root = tree();
846
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
847
+ const outer = root.children.length ? layout(root) : { open: `${eol} `, lead: `${eol} `, close: eol, colon: ': ' };
848
+ // A new or empty env is one level deeper: the root's indentation once more.
849
+ const unit = outer.lead.includes('\n') ? outer.lead.slice(outer.lead.lastIndexOf('\n') + 1) : '';
850
+ if (!findNodeAtLocation(root, ['env'])) insert(root, [`"env"${outer.colon}{}`], outer);
851
+ const env = findNodeAtLocation(tree(), ['env']);
852
+ const inner = env.children.length ? layout(env)
853
+ : { open: outer.open + unit, lead: outer.lead + unit, close: outer.close + unit, colon: outer.colon };
854
+ insert(env, entries.map(([key, value]) => `${JSON.stringify(key)}${inner.colon}${JSON.stringify(value)}`), inner);
855
+ return text;
856
+ }
857
+
858
+ function cleanRetiredSettings(settings, hookScripts) {
837
859
  let changed = cleanJarvisSettings(settings, hookScripts);
838
860
  // Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
839
861
  const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
@@ -930,8 +952,8 @@ function cleanupSettingsText(text, settings, scripts) {
930
952
  const end = !previous && next ? next.offset : node.offset + node.length;
931
953
  text = applyEdits(text, [{ offset, length: end - offset, content: '' }]);
932
954
  }
933
- const after = JSON.parse(text.replace(/^\uFEFF/, '')); // Refuse an invalid edit before changing any bytes.
934
- return Object.keys(after).length === 0 ? null : text;
955
+ JSON.parse(text.replace(/^\uFEFF/, '')); // Refuse an invalid edit before changing any bytes.
956
+ return text;
935
957
  }
936
958
 
937
959
  // Shared user roots need one writer from ownership checks through the last addon write.
@@ -1034,7 +1056,9 @@ function install(targets, global, optionalNames = [], plan, afterInstall = () =>
1034
1056
  for (const name of optionalNames) installLocalSkill(name, roots, installedSkills);
1035
1057
  afterInstall();
1036
1058
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1037
- log('The Devlyn app gives every session the principles; in a terminal, start with: npx devlyn-cli run <claude|codex|omp>');
1059
+ // `devlyn run` does not start on native Windows.
1060
+ log(process.platform === 'win32' ? 'The Devlyn app gives every session the principles.'
1061
+ : 'The Devlyn app gives every session the principles; in a terminal, start with: npx devlyn-cli run <claude|codex|omp>');
1038
1062
  noticeGlobalDrift(roots);
1039
1063
  cleanupScope(global ? os.homedir() : projectDir(), global);
1040
1064
  if (!global) noticeIgnoredInstallFiles(installedSkills);
@@ -1139,15 +1163,14 @@ function cleanupScope(base, global) {
1139
1163
  }
1140
1164
  const settingsFile = path.join(base, '.claude', 'settings.json');
1141
1165
  attempt(settingsFile, () => {
1142
- if (!realParents(settingsFile) || !fs.lstatSync(settingsFile, { throwIfNoEntry: false })?.isFile()) return;
1143
- let settings;
1144
- try { settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8').replace(/^\uFEFF/, '')); }
1145
- catch (error) { if (error instanceof SyntaxError) return; throw error; }
1146
- if (settings && typeof settings === 'object' && cleanRetiredSettings(settings)) {
1147
- found = true;
1148
- replaceFile(settingsFile, JSON.stringify(settings, null, 2) + '\n');
1149
- log(' ✕ .claude/settings.json retired entries (removed)', 'dim');
1150
- }
1166
+ const { safe, settings, text } = cleanupReadSettings(base);
1167
+ if (!safe || text === undefined) return;
1168
+ const after = cleanupSettingsText(text, settings, []);
1169
+ if (after === text) return;
1170
+ found = true;
1171
+ // Even an emptied file stays: only the reviewed cleanup, which records it first, deletes it.
1172
+ replaceFile(settingsFile, after);
1173
+ log(' ✕ .claude/settings.json retired entries (removed)', 'dim');
1151
1174
  });
1152
1175
  // CLAUDE.md and AGENTS.md stay as they are here: only a reviewed cleanup changes them.
1153
1176
  const instructions = global ? [] : ['GEMINI.md', '.cursorrules', '.windsurfrules', '.github/copilot-instructions.md'];
@@ -1972,17 +1995,18 @@ function cleanupScopes(inputs, current, includedHooks = new Set(), baseViews = n
1972
1995
  return scopes;
1973
1996
  }
1974
1997
 
1975
- function cleanupPlan(inputs) {
1998
+ // A checkout-only plan is a count of the checkout as it stands: no fetch, base snapshot or checkout update.
1999
+ function cleanupPlan(inputs, checkoutOnly = false) {
1976
2000
  const current = cleanupCurrentFingerprints();
1977
- const baseViews = new Map();
2001
+ const baseViews = checkoutOnly ? null : new Map();
1978
2002
  try {
1979
2003
  // The review covers the bytes delivery acts on, including scopes sharing a repository.
1980
2004
  const scopes = cleanupScopes(inputs, current, new Set(), baseViews);
1981
- const plan = { schemaVersion: 1, version: PKG.version, inputs, scopes };
2005
+ const plan = { schemaVersion: 1, version: PKG.version, ...(checkoutOnly && { checkoutOnly }), inputs, scopes };
1982
2006
  return { ...plan, digest: cleanupDigest(plan) };
1983
2007
  } finally {
1984
2008
  const errors = [];
1985
- for (const view of baseViews.values()) {
2009
+ for (const view of baseViews?.values() || []) {
1986
2010
  if (!view.snapshot) continue;
1987
2011
  try { cleanupRemoveSnapshot(view.root, view.snapshot); } catch (error) { errors.push(error); }
1988
2012
  }
@@ -2032,8 +2056,9 @@ function cleanupApplyItem(item) {
2032
2056
  const { safe, settings, text } = cleanupReadSettings(item.base);
2033
2057
  if (!safe || text === undefined) return cleanupReason('stale-plan');
2034
2058
  const after = cleanupSettingsText(text, settings, item.hookScripts);
2035
- if (after === null) fs.unlinkSync(item.path);
2036
- else if (after !== text) replaceFile(item.path, after);
2059
+ if (after === text) return null;
2060
+ if (Object.keys(JSON.parse(after.replace(/^\uFEFF/, ''))).length === 0) fs.unlinkSync(item.path);
2061
+ else replaceFile(item.path, after);
2037
2062
  return null;
2038
2063
  }
2039
2064
  return cleanupReason('unknown-item');
@@ -2428,21 +2453,30 @@ function cleanupCommand(args) {
2428
2453
  const { options, fail } = require('./release');
2429
2454
  const opts = options(args, {
2430
2455
  plan: { type: 'boolean' }, apply: { type: 'boolean' }, machine: { type: 'boolean' }, events: { type: 'boolean' },
2456
+ 'checkout-only': { type: 'boolean' },
2431
2457
  project: { type: 'string', multiple: true }, 'account-home': { type: 'string', multiple: true },
2432
2458
  'plan-file': { type: 'string' }, digest: { type: 'string' }, include: { type: 'string', multiple: true },
2433
2459
  });
2434
2460
  if (opts.plan === opts.apply) fail('invalid-arguments', { argument: 'exactly one of --plan or --apply' }, 2);
2435
2461
  if (opts.plan && opts.include) fail('invalid-arguments', { argument: '--include requires --apply' }, 2);
2462
+ if (opts.apply && opts['checkout-only']) fail('invalid-arguments', { argument: '--checkout-only requires --plan' }, 2);
2436
2463
  for (const dir of [...(opts.project || []), ...(opts['account-home'] || [])]) {
2437
2464
  if (!path.isAbsolute(dir)) fail('invalid-arguments', { argument: 'absolute paths required' }, 2);
2438
2465
  }
2439
2466
  // The installer's progress lines would corrupt this command's JSON on stdout.
2440
2467
  console.log = (...parts) => process.stderr.write(`${parts.join(' ')}\n`);
2441
2468
  if (opts.plan) {
2442
- return cleanupPlan({ machine: Boolean(opts.machine), projects: opts.project || [], accountHomes: opts['account-home'] || [] });
2469
+ // Every process a passive count starts inherits this, so no Git process, the queue inspection's
2470
+ // included, fetches missing objects or takes optional locks. Git before 2.45 ignores
2471
+ // GIT_NO_LAZY_FETCH; every Git refuses all transports under GIT_ALLOW_PROTOCOL=none.
2472
+ if (opts['checkout-only']) Object.assign(process.env, { GIT_NO_LAZY_FETCH: '1', GIT_ALLOW_PROTOCOL: 'none', GIT_OPTIONAL_LOCKS: '0' });
2473
+ return cleanupPlan({ machine: Boolean(opts.machine), projects: opts.project || [], accountHomes: opts['account-home'] || [] },
2474
+ Boolean(opts['checkout-only']));
2443
2475
  }
2444
2476
  if (!opts['plan-file'] || !opts.digest) fail('invalid-arguments', { argument: '--plan-file and --digest' }, 2);
2445
2477
  const plan = JSON.parse(fs.readFileSync(opts['plan-file'], 'utf8'));
2478
+ // A checkout-only plan never reviewed the base that delivery acts on.
2479
+ if (plan?.checkoutOnly !== undefined) fail('checkout-only-plan', {}, 2);
2446
2480
  const inputs = plan?.inputs;
2447
2481
  if (plan?.schemaVersion !== 1 || plan.digest !== opts.digest || cleanupDigest(plan) !== plan.digest
2448
2482
  || typeof inputs?.machine !== 'boolean' || !Array.isArray(inputs.projects) || !Array.isArray(inputs.accountHomes)
package/bin/engines.js CHANGED
@@ -1,7 +1,8 @@
1
- // Engine IDs from devlyn-os-v1/packages/contracts/src/engines.ts, in inventory order.
2
- // CLI binary names (for example agy) are not engine IDs.
1
+ // Engine IDs in the order of devlyn-os-v1/packages/contracts/src/engines.ts. An ID joins only once its
2
+ // channels are confirmed on the real CLI, with that examined version as its floor below; the planner
3
+ // refuses every other ID. CLI binary names (for example agy) are not engine IDs.
3
4
  const ENGINE_IDS = Object.freeze([
4
- 'claude', 'codex', 'grok', 'omp', 'antigravity', 'cursor', 'opencode', 'kimi', 'copilot', 'qwen',
5
+ 'claude', 'codex', 'grok', 'omp', 'antigravity', 'cursor', 'opencode', 'kimi', 'copilot', 'qwen', 'pi',
5
6
  ]);
6
7
 
7
8
  // The versions each channel was examined on. Any newer release, a new major included, is taken as
@@ -20,6 +21,7 @@ const ENGINE_CAPABILITIES = Object.freeze({
20
21
  kimi: { minimum: '2.3.0', executor: false },
21
22
  copilot: { minimum: '1.0.98', executor: false },
22
23
  qwen: { minimum: '0.26.0', executor: false },
24
+ pi: { minimum: '1.4.0', executor: false },
23
25
  });
24
26
 
25
27
  function readableVersion(engine, version) {