devlyn-cli 4.3.5 → 4.3.6

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,114 @@ 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
1846
  let prepared;
1847
- try { prepared = cleanupDeliveryCommit(worktree, items, current, release); }
1847
+ try { prepared = cleanupDeliveryCommit(worktree, items, current, release, cleanupRemovedUpstream(scope.root, worktree)); }
1848
1848
  catch (error) { return release(cleanupReason('apply-failed', { detail: error.message })); }
1849
1849
  if (prepared.error) return prepared;
1850
+ // Every reviewed copy is already gone from the remote base: the checkout only has to take it.
1851
+ if (!prepared.acceptance) return cleanupUpdateCheckout(scope);
1850
1852
  const completed = lastJson(run(['complete', '--receipt', allocated.receipt, '--acceptance', prepared.acceptance, '--writers-stopped'], scope.root).stdout);
1851
1853
  const status = completed?.delivery_status || completed?.status;
1852
- if (status === 'COMPLETE' || status === 'MERGED') return { delivery: { state: 'merged', url: completed.pr || null } };
1854
+ if (status === 'COMPLETE' || status === 'MERGED') return { ...cleanupUpdateCheckout(scope), delivery: { state: 'merged', url: completed.pr || null } };
1853
1855
  if (completed?.pr) return { delivery: { state: 'pr-open', url: completed.pr } };
1854
1856
  const reason = completed?.reason || 'delivery did not complete';
1855
1857
  const auth = /\b(authentication|unauthorized|gh auth login|could not read Username|HTTP 40[13])\b/i.test(reason);
1856
1858
  return { error: cleanupReason(auth ? 'auth-failed' : 'delivery-failed', { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
1857
1859
  }
1858
1860
 
1861
+ // Whether the remote base (the allocated worktree's HEAD) removed a copy this checkout tracks: the
1862
+ // base holds no path of that name in any spelling, and the checkout holds the copy, under its
1863
+ // tracked spelling, as it was where the two histories meet. A copy committed only here, or changed
1864
+ // here since, is not one the base removed.
1865
+ function cleanupRemovedUpstream(root, worktree) {
1866
+ const read = (cwd, args, options) => {
1867
+ const result = cleanupGit(cwd, args, options);
1868
+ return !result.error && result.status === 0 ? result.stdout : null;
1869
+ };
1870
+ const object = (spec) => read(root, ['rev-parse', '--verify', '--quiet', spec])?.trim() || null;
1871
+ const base = read(worktree, ['rev-parse', 'HEAD'])?.trim();
1872
+ const fork = base ? read(root, ['merge-base', 'HEAD', base])?.trim() : null;
1873
+ let upstream; // the base's paths, folded to lower case, read once and only when needed
1874
+ return (relative) => {
1875
+ if (!fork) return false;
1876
+ if (upstream === undefined) {
1877
+ upstream = read(root, ['ls-tree', '-r', '-z', '--name-only', base], { maxBuffer: 1 << 30 })
1878
+ ?.split('\0').filter(Boolean).map((name) => name.toLowerCase()) ?? null;
1879
+ }
1880
+ const lower = relative.toLowerCase();
1881
+ if (!upstream || upstream.some((name) => name === lower || name.startsWith(`${lower}/`))) return false;
1882
+ // The tracked spelling, which a case-insensitive file system lets differ from the one on disk.
1883
+ const tracked = read(root, ['ls-files', '-z', '--', `:(icase,literal)${relative}`])?.split('\0').find(Boolean);
1884
+ const spelled = tracked?.slice(0, relative.length);
1885
+ if (!spelled || spelled.toLowerCase() !== lower) return false;
1886
+ const before = object(`${fork}:${spelled}`);
1887
+ return before !== null && before === object(`HEAD:${spelled}`);
1888
+ };
1889
+ }
1890
+
1891
+ // The copies the remote base dropped stay in this checkout until it takes that base: a checkout on
1892
+ // the base branch is fast-forwarded to it, as a task's owner reconciles after delivery. The base is
1893
+ // fetched into a ref this call owns (FETCH_HEAD can be rewritten by another fetch meanwhile) and
1894
+ // merged by its commit, only while the checkout is still the one checked before the fetch, and the
1895
+ // result must be that commit. Another branch, or a fast-forward Git refuses (local changes or ignored
1896
+ // files in the way, local commits), is left as it is.
1897
+ function cleanupUpdateCheckout(scope) {
1898
+ const failed = (detail) => ({ error: cleanupReason('update-checkout', { detail }) });
1899
+ const read = (args) => {
1900
+ const result = cleanupGit(scope.root, args);
1901
+ return !result.error && result.status === 0 ? result.stdout.trim() : null;
1902
+ };
1903
+ const checkout = () => [read(['symbolic-ref', '--quiet', '--short', 'HEAD']), read(['rev-parse', 'HEAD'])];
1904
+ const [branch, head] = checkout();
1905
+ if (branch !== scope.remote.base || !head) return failed(`${branch || 'a detached HEAD'} is checked out`);
1906
+ const ref = `refs/devlyn/cleanup/${crypto.randomBytes(8).toString('hex')}`;
1907
+ try {
1908
+ const fetched = cleanupGit(scope.root, ['fetch', '--quiet', '--no-write-fetch-head', 'origin', `+refs/heads/${scope.remote.base}:${ref}`]);
1909
+ if (fetched.error || fetched.status !== 0) return failed(fetched.error?.message || fetched.stderr.trim());
1910
+ const target = read(['rev-parse', '--verify', `${ref}^{commit}`]);
1911
+ const [now, at] = checkout();
1912
+ if (!target || now !== branch || at !== head) return failed('the checkout changed while the base was fetched');
1913
+ // The branch's own merge options (`--squash`, `-s ours`) would turn this into something else.
1914
+ const merged = cleanupGit(scope.root, ['-c', `branch.${branch}.mergeOptions=`,
1915
+ 'merge', '--ff-only', '--no-squash', '--no-autostash', '--no-overwrite-ignore', '--quiet', target]);
1916
+ if (merged.error || merged.status !== 0) return failed(merged.error?.message || merged.stderr.trim());
1917
+ const [after, landed] = checkout();
1918
+ return after === branch && landed === target ? {} : failed('the fast-forward did not land on the base');
1919
+ } finally {
1920
+ cleanupGit(scope.root, ['update-ref', '-d', ref]);
1921
+ }
1922
+ }
1923
+
1859
1924
  // Inside the allocated worktree: prove every reviewed item, remove them, commit, and write the
1860
1925
  // acceptance completion binds. A failure releases the worktree; so does a throw, in the caller.
1861
- function cleanupDeliveryCommit(worktree, items, current, release) {
1926
+ // With every reviewed copy already removed by the remote base, the worktree is released with
1927
+ // nothing to deliver.
1928
+ function cleanupDeliveryCommit(worktree, items, current, release, removedUpstream) {
1862
1929
  const fresh = cleanupProjectScope(worktree, current);
1863
1930
  const available = new Map(fresh.items.map((item) => [`${item.kind}\u0000${item.relative}`, item]));
1864
1931
  const pairs = items.map((reviewed) => {
1865
1932
  const fresh = available.get(`${reviewed.kind}\u0000${reviewed.relative}`);
1866
1933
  return { reviewed, fresh, before: fresh && cleanupPreimage(fresh.path) };
1867
1934
  });
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);
1935
+ const stale = pairs.filter(({ reviewed, fresh, before }) => (fresh
1936
+ ? fresh.action !== reviewed.action || fresh.route !== 'delivery'
1937
+ || before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256
1938
+ : !removedUpstream(reviewed.relative)));
1870
1939
  // Check the entire batch before mutating even one item; the remote base cannot replace the reviewed preimage.
1871
1940
  if (stale.length) return release(cleanupReason('stale-plan', { detail: stale.map(({ reviewed }) => reviewed.relative).join(', ') }));
1941
+ const present = pairs.filter(({ fresh }) => fresh);
1942
+ if (!present.length) return release();
1872
1943
  const applied = [];
1873
- for (const { reviewed, fresh } of pairs) {
1944
+ for (const { reviewed, fresh } of present) {
1874
1945
  const error = cleanupApplyItem({ ...fresh, before: reviewed.before });
1875
1946
  if (error) return release(cleanupReason(error.code, { ...error.params, detail: reviewed.relative }));
1876
1947
  applied.push(fresh.relative);
@@ -766,6 +766,7 @@
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"
770
771
  ]
771
772
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.5",
3
+ "version": "4.3.6",
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-6",
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.6",
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.6",
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.6",
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": "6eb4d078cc4fe235517b7a08e8d6965462cd6c91ab1853cc2f8a9f0b829f6191",
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": "c0aa4b8a25ec6873011859bc5c76cad58d1cd468e13018ea85761b8db51b81e9",
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": "4ca0474d48fdbee91769be02bd6a0b612313a390b0b0fe244679df0a43bc2e37",
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": "f8189a7e93f4e5ff3334d8ad884fffa679a2729f10fff815d24c4f0067534c73",
443
443
  "executable": false
444
444
  },
445
445
  {
446
446
  "path": "native/codex/plugins/devlyn/.codex-plugin/plugin.json",
447
- "sha256": "c98a45d3472202762065f334608b691be0e092dbb462d50e8409e7a7f4f8ef79",
447
+ "sha256": "4ca0474d48fdbee91769be02bd6a0b612313a390b0b0fe244679df0a43bc2e37",
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": "4d754dc7026976c1e37d7d74b6733459fcd07b6a498bc3e8b4c4d575b02a7700",
1623
1623
  "executable": false
1624
1624
  }
1625
1625
  ],
1626
1626
  "digest": {
1627
1627
  "algorithm": "sha256",
1628
- "value": "9eb18506f59eae355306d846f340a7f1ac5ed59f3009c5bafb5f846a56cac45b"
1628
+ "value": "f4ee05e55111300285e2ec7b435934786fee40501365c8a2a2706b7ee168bc20"
1629
1629
  }
1630
1630
  }