devlyn-cli 4.3.5 → 4.3.7

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/bin/devlyn.js CHANGED
@@ -1564,8 +1564,8 @@ function cleanupCurrentFingerprints() {
1564
1564
  return Object.fromEntries([...CLEANUP_CORE, '_shared'].map((name) => [name, fingerprint(path.join(CONFIG_SOURCE, 'skills', name))]));
1565
1565
  }
1566
1566
 
1567
- function cleanupGit(root, args) {
1568
- return spawnSync('git', ['-C', root, ...args], { env: { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' }, encoding: 'utf8' });
1567
+ function cleanupGit(root, args, options = {}) {
1568
+ return spawnSync('git', ['-C', root, ...args], { env: { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' }, encoding: 'utf8', ...options });
1569
1569
  }
1570
1570
 
1571
1571
  let cleanupPythonBinary;
@@ -1834,43 +1834,129 @@ function cleanupDeliver(scope, items, current) {
1834
1834
  '--branch', branch, '--worktree', worktree, '--repository', scope.remote.repository,
1835
1835
  '--remote', 'origin', '--base', scope.remote.base], scope.root).stdout);
1836
1836
  if (allocated?.status !== 'ALLOCATED') return { error: cleanupReason('delivery-failed', { detail: allocated?.reason || 'allocation failed' }) };
1837
- const release = (error) => {
1837
+ const release = (error = null) => {
1838
1838
  for (const args of [['worktree', 'remove', '--force', worktree], ['branch', '-D', branch]]) {
1839
1839
  const result = cleanupGit(scope.root, args);
1840
- if (result.error || result.status !== 0) return { error: cleanupReason(error.code, { ...error.params,
1840
+ if (result.error || result.status !== 0) return { error: cleanupReason(error?.code || 'delivery-failed', { ...error?.params,
1841
1841
  releaseError: result.error?.message || result.stderr.trim(), worktree, receipt: allocated.receipt }) };
1842
1842
  }
1843
1843
  fs.rmSync(path.dirname(allocated.receipt), { recursive: true, force: true });
1844
- return { error };
1844
+ return error ? { error } : {};
1845
1845
  };
1846
+ const baseHead = cleanupGit(worktree, ['rev-parse', 'HEAD']);
1847
+ if (baseHead.error || baseHead.status !== 0) return release(cleanupReason('repository-unreadable', { detail: baseHead.error?.message || baseHead.stderr.trim() }));
1848
+ const base = baseHead.stdout.trim();
1846
1849
  let prepared;
1847
- try { prepared = cleanupDeliveryCommit(worktree, items, current, release); }
1850
+ try { prepared = cleanupDeliveryCommit(worktree, items, current, release, cleanupSettledUpstream(scope.root, base)); }
1848
1851
  catch (error) { return release(cleanupReason('apply-failed', { detail: error.message })); }
1852
+ if (prepared.error?.code === 'stale-plan') {
1853
+ const head = cleanupGit(scope.root, ['rev-parse', 'HEAD']);
1854
+ // A stale review must see the base's bytes next time. Local commits keep today's refusal.
1855
+ if (!head.error && head.status === 0 && head.stdout.trim() !== base) {
1856
+ const ancestor = cleanupGit(scope.root, ['merge-base', '--is-ancestor', head.stdout.trim(), base]);
1857
+ if (!ancestor.error && ancestor.status === 0) {
1858
+ const updated = cleanupUpdateCheckout(scope);
1859
+ if (updated.error) return updated;
1860
+ }
1861
+ }
1862
+ }
1849
1863
  if (prepared.error) return prepared;
1864
+ // The remote base already settled every reviewed item: the checkout only has to take it.
1865
+ if (!prepared.acceptance) return cleanupUpdateCheckout(scope);
1850
1866
  const completed = lastJson(run(['complete', '--receipt', allocated.receipt, '--acceptance', prepared.acceptance, '--writers-stopped'], scope.root).stdout);
1851
1867
  const status = completed?.delivery_status || completed?.status;
1852
- if (status === 'COMPLETE' || status === 'MERGED') return { delivery: { state: 'merged', url: completed.pr || null } };
1868
+ if (status === 'COMPLETE' || status === 'MERGED') return { ...cleanupUpdateCheckout(scope), delivery: { state: 'merged', url: completed.pr || null } };
1853
1869
  if (completed?.pr) return { delivery: { state: 'pr-open', url: completed.pr } };
1854
1870
  const reason = completed?.reason || 'delivery did not complete';
1855
1871
  const auth = /\b(authentication|unauthorized|gh auth login|could not read Username|HTTP 40[13])\b/i.test(reason);
1856
1872
  return { error: cleanupReason(auth ? 'auth-failed' : 'delivery-failed', { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
1857
1873
  }
1858
1874
 
1875
+ // With no fresh counterpart, the base settled an item only if it changed the path's content or
1876
+ // removed it in every spelling, while the checkout holds it under its tracked spelling unchanged
1877
+ // since the fork point. A copy committed or changed only here is not one the base settled.
1878
+ function cleanupSettledUpstream(root, base) {
1879
+ const read = (cwd, args, options) => {
1880
+ const result = cleanupGit(cwd, args, options);
1881
+ return !result.error && result.status === 0 ? result.stdout : null;
1882
+ };
1883
+ const object = (spec) => read(root, ['rev-parse', '--verify', '--quiet', spec])?.trim() || null;
1884
+ const fork = read(root, ['merge-base', 'HEAD', base])?.trim();
1885
+ let upstream; // the base's paths, folded to lower case, read once and only when needed
1886
+ return (relative) => {
1887
+ if (!fork) return false;
1888
+ if (upstream === undefined) {
1889
+ upstream = read(root, ['ls-tree', '-r', '-z', '--name-only', base], { maxBuffer: 1 << 30 })
1890
+ ?.split('\0').filter(Boolean).map((name) => name.toLowerCase()) ?? null;
1891
+ }
1892
+ const lower = relative.toLowerCase();
1893
+ if (!upstream) return false;
1894
+ // The tracked spelling, which a case-insensitive file system lets differ from the one on disk.
1895
+ const tracked = read(root, ['ls-files', '-z', '--', `:(icase,literal)${relative}`])?.split('\0').find(Boolean);
1896
+ const spelled = tracked?.slice(0, relative.length);
1897
+ if (!spelled || spelled.toLowerCase() !== lower) return false;
1898
+ const before = object(`${fork}:${spelled}`);
1899
+ if (before === null || before !== object(`HEAD:${spelled}`)) return false;
1900
+ const after = object(`${base}:${spelled}`);
1901
+ return after !== null ? after !== before
1902
+ : !upstream.some((name) => name === lower || name.startsWith(`${lower}/`));
1903
+ };
1904
+ }
1905
+
1906
+ // The remote base's cleanup or changed copies reach this checkout only when it takes that base: a checkout on
1907
+ // the base branch is fast-forwarded to it, as a task's owner reconciles after delivery. The base is
1908
+ // fetched into a ref this call owns (FETCH_HEAD can be rewritten by another fetch meanwhile) and
1909
+ // merged by its commit, only while the checkout is still the one checked before the fetch, and the
1910
+ // result must be that commit. Another branch, or a fast-forward Git refuses (local changes or ignored
1911
+ // files in the way, local commits), is left as it is.
1912
+ function cleanupUpdateCheckout(scope) {
1913
+ const failed = (detail) => ({ error: cleanupReason('update-checkout', { detail }) });
1914
+ const read = (args) => {
1915
+ const result = cleanupGit(scope.root, args);
1916
+ return !result.error && result.status === 0 ? result.stdout.trim() : null;
1917
+ };
1918
+ const checkout = () => [read(['symbolic-ref', '--quiet', '--short', 'HEAD']), read(['rev-parse', 'HEAD'])];
1919
+ const [branch, head] = checkout();
1920
+ if (branch !== scope.remote.base || !head) return failed(`${branch || 'a detached HEAD'} is checked out`);
1921
+ const ref = `refs/devlyn/cleanup/${crypto.randomBytes(8).toString('hex')}`;
1922
+ try {
1923
+ const fetched = cleanupGit(scope.root, ['fetch', '--quiet', '--no-write-fetch-head', 'origin', `+refs/heads/${scope.remote.base}:${ref}`]);
1924
+ if (fetched.error || fetched.status !== 0) return failed(fetched.error?.message || fetched.stderr.trim());
1925
+ const target = read(['rev-parse', '--verify', `${ref}^{commit}`]);
1926
+ const [now, at] = checkout();
1927
+ if (!target || now !== branch || at !== head) return failed('the checkout changed while the base was fetched');
1928
+ // The branch's own merge options (`--squash`, `-s ours`) would turn this into something else.
1929
+ const merged = cleanupGit(scope.root, ['-c', `branch.${branch}.mergeOptions=`,
1930
+ 'merge', '--ff-only', '--no-squash', '--no-autostash', '--no-overwrite-ignore', '--quiet', target]);
1931
+ if (merged.error || merged.status !== 0) return failed(merged.error?.message || merged.stderr.trim());
1932
+ const [after, landed] = checkout();
1933
+ return after === branch && landed === target ? {} : failed('the fast-forward did not land on the base');
1934
+ } finally {
1935
+ cleanupGit(scope.root, ['update-ref', '-d', ref]);
1936
+ }
1937
+ }
1938
+
1859
1939
  // Inside the allocated worktree: prove every reviewed item, remove them, commit, and write the
1860
1940
  // acceptance completion binds. A failure releases the worktree; so does a throw, in the caller.
1861
- function cleanupDeliveryCommit(worktree, items, current, release) {
1941
+ // With every reviewed item already settled by the remote base, the worktree is released with
1942
+ // nothing to deliver.
1943
+ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstream) {
1862
1944
  const fresh = cleanupProjectScope(worktree, current);
1863
1945
  const available = new Map(fresh.items.map((item) => [`${item.kind}\u0000${item.relative}`, item]));
1864
1946
  const pairs = items.map((reviewed) => {
1865
1947
  const fresh = available.get(`${reviewed.kind}\u0000${reviewed.relative}`);
1866
1948
  return { reviewed, fresh, before: fresh && cleanupPreimage(fresh.path) };
1867
1949
  });
1868
- const stale = pairs.filter(({ reviewed, fresh, before }) => !fresh || fresh.action !== reviewed.action || fresh.route !== 'delivery'
1869
- || before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256);
1950
+ const stale = pairs.filter(({ reviewed, fresh, before }) => (fresh
1951
+ ? fresh.action !== reviewed.action || fresh.route !== 'delivery'
1952
+ || before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256
1953
+ : !settledUpstream(reviewed.relative)));
1870
1954
  // Check the entire batch before mutating even one item; the remote base cannot replace the reviewed preimage.
1871
1955
  if (stale.length) return release(cleanupReason('stale-plan', { detail: stale.map(({ reviewed }) => reviewed.relative).join(', ') }));
1956
+ const present = pairs.filter(({ fresh }) => fresh);
1957
+ if (!present.length) return release();
1872
1958
  const applied = [];
1873
- for (const { reviewed, fresh } of pairs) {
1959
+ for (const { reviewed, fresh } of present) {
1874
1960
  const error = cleanupApplyItem({ ...fresh, before: reviewed.before });
1875
1961
  if (error) return release(cleanupReason(error.code, { ...error.params, detail: reviewed.relative }));
1876
1962
  applied.push(fresh.relative);
@@ -766,6 +766,8 @@
766
766
  "4.3.2",
767
767
  "4.3.3",
768
768
  "4.3.4",
769
- "4.3.5"
769
+ "4.3.5",
770
+ "4.3.6",
771
+ "4.3.7"
770
772
  ]
771
773
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.5",
3
+ "version": "4.3.7",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "devlyn-harness-v4-3-5",
2
+ "name": "devlyn-harness-v4-3-7",
3
3
  "plugins": [
4
4
  {
5
5
  "name": "devlyn",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.5",
3
+ "version": "4.3.7",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.3.5",
3
+ "version": "4.3.7",
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": {
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "devlyn-cli",
4
- "version": "4.3.5",
4
+ "version": "4.3.7",
5
5
  "principles": {
6
6
  "defaults": "config/skills/_shared/runtime-principles.md",
7
7
  "memory": {
@@ -129,7 +129,7 @@
129
129
  },
130
130
  {
131
131
  "path": "bin/devlyn.js",
132
- "sha256": "8ba08fe52c9529dee1bf13498ab3237fc25830f7f7305337fd832f32d158be7e",
132
+ "sha256": "5841ad32184de3095edffc5d05ae78dded34dc0ce829e8f805218de8b0085b1f",
133
133
  "executable": true
134
134
  },
135
135
  {
@@ -164,7 +164,7 @@
164
164
  },
165
165
  {
166
166
  "path": "bin/skill-history.json",
167
- "sha256": "9f05ea3fb4cb17ee7b907e4ade9e8d337e02e0b027900d4f10b4285659c0d611",
167
+ "sha256": "8272d3975ef06e1108c16c0d56b2178a4de73e2020f82f1f10a50c2a3d5ddb46",
168
168
  "executable": false
169
169
  },
170
170
  {
@@ -309,7 +309,7 @@
309
309
  },
310
310
  {
311
311
  "path": "native/claude/.claude-plugin/plugin.json",
312
- "sha256": "c98a45d3472202762065f334608b691be0e092dbb462d50e8409e7a7f4f8ef79",
312
+ "sha256": "c582e887aaad4322678e85ba02997ff51ef8f30d2fa50458024787884422117b",
313
313
  "executable": false
314
314
  },
315
315
  {
@@ -439,12 +439,12 @@
439
439
  },
440
440
  {
441
441
  "path": "native/codex/.agents/plugins/marketplace.json",
442
- "sha256": "27ded4de671a2051d0b3408449e1c54259575499ee1ab030b54560bef253b966",
442
+ "sha256": "74699fb150844bc39ebd1cf11696c993d22c7f9add43b21e8656efd8af3f70de",
443
443
  "executable": false
444
444
  },
445
445
  {
446
446
  "path": "native/codex/plugins/devlyn/.codex-plugin/plugin.json",
447
- "sha256": "c98a45d3472202762065f334608b691be0e092dbb462d50e8409e7a7f4f8ef79",
447
+ "sha256": "c582e887aaad4322678e85ba02997ff51ef8f30d2fa50458024787884422117b",
448
448
  "executable": false
449
449
  },
450
450
  {
@@ -1619,12 +1619,12 @@
1619
1619
  },
1620
1620
  {
1621
1621
  "path": "package.json",
1622
- "sha256": "17b8bb37a212506ba6b8cb78ae7339f373e8b98f1948ae4c6d4ce7b5baa2171d",
1622
+ "sha256": "0450b10c13cf6b932b441138368a46060b2a2278bfde57474bb73b4ceb160de9",
1623
1623
  "executable": false
1624
1624
  }
1625
1625
  ],
1626
1626
  "digest": {
1627
1627
  "algorithm": "sha256",
1628
- "value": "9eb18506f59eae355306d846f340a7f1ac5ed59f3009c5bafb5f846a56cac45b"
1628
+ "value": "05823285e84ce3fc21e7003776b7216cbdbb78b7f4cc0f5029d2976736326f59"
1629
1629
  }
1630
1630
  }