devlyn-cli 4.2.15 → 4.2.16
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/skill-history.json
CHANGED
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"5995fcdb203a6b98c3e7cae4fe6cc7a3fb57f06a5d32069576e58350d50eab6f",
|
|
59
59
|
"59f0ecc2c1fd9620e4cac06be370e0bf9965a35c20616047445dbc42da6a1677",
|
|
60
60
|
"5be2e643f96b6ce503ae8d631c67729f0c24b6c5d2b431c087161b520ccbe417",
|
|
61
|
+
"60cdd1a8b220e629bcba3a24652323d16e3e6a15ba5b07c69ef2785a9c2eca70",
|
|
61
62
|
"61ec4c0435a2f0b307df8e88d81d9862c3abc1bf758648f2c18039581596659b",
|
|
62
63
|
"628a40e873d156e9179fd5a6bb3da9f910b6dde0886055804a506cdf8a631aaa",
|
|
63
64
|
"628ee7de57717804cb2b4f4c9357751578276f241eb3ab53fdee49ae92d57513",
|
|
@@ -521,6 +521,18 @@ def raise_walk_error(error):
|
|
|
521
521
|
raise error
|
|
522
522
|
|
|
523
523
|
|
|
524
|
+
def records_manifest(root):
|
|
525
|
+
require(root == root.resolve() and root.is_dir(), f"records root must be a nonsymlink directory; repair and resume: {root}")
|
|
526
|
+
devlyn = safe_path(root, ".devlyn")
|
|
527
|
+
if not devlyn.exists():
|
|
528
|
+
return {}
|
|
529
|
+
require(devlyn.is_dir(), f".devlyn records must be a directory; repair and resume: {devlyn}")
|
|
530
|
+
paths = [str((Path(directory) / name).relative_to(root))
|
|
531
|
+
for directory, dirs, files in os.walk(devlyn, onerror=raise_walk_error)
|
|
532
|
+
for name in files + [name for name in dirs if (Path(directory) / name).is_symlink()]]
|
|
533
|
+
return snapshot_files(root, paths) if paths else {}
|
|
534
|
+
|
|
535
|
+
|
|
524
536
|
def clean_scratch(receipt, path, writers_stopped):
|
|
525
537
|
"""Empty the owned build directory; keep its identity across resume/reuse."""
|
|
526
538
|
scratch = path.parent / "scratch"
|
|
@@ -583,23 +595,20 @@ def cleanup(receipt, path, pr):
|
|
|
583
595
|
require(receipt.get("writers_released"), "wait actual children, stop/yield known writers, then resume with --writers-stopped")
|
|
584
596
|
require(work != Path(receipt["anchor"]) and work != Path(receipt["common_gitdir"]).parent, "cannot remove retained/main checkout")
|
|
585
597
|
removable(receipt, work)
|
|
586
|
-
|
|
587
|
-
if
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if
|
|
593
|
-
records =
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
verify_files(Path(temporary), records)
|
|
599
|
-
Path(temporary).rename(destination)
|
|
600
|
-
verify_files(destination, records)
|
|
601
|
-
verify_files(work, records)
|
|
598
|
+
records = records_manifest(work)
|
|
599
|
+
if records:
|
|
600
|
+
destination = path.parent / "records"
|
|
601
|
+
if os.path.lexists(destination) and records_manifest(destination) != records:
|
|
602
|
+
digest = hashlib.sha256(json.dumps(records, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
|
|
603
|
+
destination = path.parent / ("records." + digest)
|
|
604
|
+
if not os.path.lexists(destination):
|
|
605
|
+
with tempfile.TemporaryDirectory(prefix=".records-", dir=path.parent) as temporary:
|
|
606
|
+
shutil.copytree(safe_path(work, ".devlyn"), Path(temporary) / ".devlyn", symlinks=True)
|
|
607
|
+
require(records_manifest(Path(temporary)) == records, ".devlyn changed while it was copied; stop its writer and resume")
|
|
608
|
+
Path(temporary).rename(destination)
|
|
609
|
+
require(records_manifest(destination) == records, f"records copy mismatch; repair and resume: {destination}")
|
|
602
610
|
removable(receipt, work)
|
|
611
|
+
require(records_manifest(work) == records, ".devlyn changed after it was copied; stop its writer and resume")
|
|
603
612
|
gref(receipt, "worktree", "remove", str(work))
|
|
604
613
|
else:
|
|
605
614
|
require(str(work) not in registrations(receipt), "missing worktree is still registered; retain")
|
|
@@ -1637,10 +1646,16 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
1637
1646
|
self.assertEqual(result["status"], "PR")
|
|
1638
1647
|
self.assertTrue(json.loads(receipt.read_text())["writers_released"])
|
|
1639
1648
|
self.merge_pr()
|
|
1649
|
+
original = records_manifest(task)
|
|
1650
|
+
shutil.copytree(task / ".devlyn", receipt.parent / "records/.devlyn")
|
|
1651
|
+
(task / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
|
|
1652
|
+
final = records_manifest(task)
|
|
1640
1653
|
server = json.loads(self.data.read_text())
|
|
1641
1654
|
result = self.allocate("released")
|
|
1642
1655
|
self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
|
|
1643
1656
|
self.assertFalse(task.exists())
|
|
1657
|
+
self.assertEqual(records_manifest(receipt.parent / "records"), original)
|
|
1658
|
+
self.assertEqual(records_manifest(next(receipt.parent.glob("records.*"))), final)
|
|
1644
1659
|
self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
|
|
1645
1660
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
1646
1661
|
current = json.loads(self.data.read_text())
|
|
@@ -1840,16 +1855,123 @@ ctypes.CDLL(None).pthread_exit(None)
|
|
|
1840
1855
|
saved = self.receipt.parent / "records/.devlyn"
|
|
1841
1856
|
shutil.copytree(self.task / ".devlyn", saved)
|
|
1842
1857
|
(saved / "checks.txt").write_bytes(b"tampered copy")
|
|
1843
|
-
|
|
1844
|
-
self.
|
|
1858
|
+
final = records_manifest(self.task)
|
|
1859
|
+
result, _ = self.complete()
|
|
1860
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1861
|
+
self.assertFalse(self.task.exists())
|
|
1845
1862
|
self.assertEqual((saved / "checks.txt").read_bytes(), b"tampered copy")
|
|
1863
|
+
self.assertEqual(records_manifest(next(self.receipt.parent.glob("records.*"))), final)
|
|
1864
|
+
|
|
1865
|
+
def test_records_resume_preserves_added_and_changed_files(self):
|
|
1866
|
+
self.allocate(); self.accept()
|
|
1867
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1868
|
+
self.merge_pr()
|
|
1869
|
+
extra = self.task / ".devlyn/extra.log"
|
|
1870
|
+
extra.write_bytes(b"before\n")
|
|
1871
|
+
saved = self.receipt.parent / "records"
|
|
1872
|
+
shutil.copytree(self.task / ".devlyn", saved / ".devlyn")
|
|
1873
|
+
original = records_manifest(saved)
|
|
1874
|
+
extra.write_bytes(b"after\n")
|
|
1875
|
+
extra.chmod(0o640)
|
|
1876
|
+
(self.task / ".devlyn/storage-cleanup.json").write_bytes(b'{"status":"retained"}\n')
|
|
1877
|
+
final = records_manifest(self.task)
|
|
1878
|
+
digest = hashlib.sha256(json.dumps(final, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
|
|
1879
|
+
result, _ = self.complete()
|
|
1880
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1881
|
+
self.assertFalse(self.task.exists())
|
|
1882
|
+
self.assertEqual(records_manifest(saved), original)
|
|
1883
|
+
self.assertEqual(records_manifest(self.receipt.parent / ("records." + digest)), final)
|
|
1884
|
+
|
|
1885
|
+
def test_records_resume_preserves_deleted_file_only_in_earlier_copy(self):
|
|
1886
|
+
self.allocate(); self.accept()
|
|
1887
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1888
|
+
self.merge_pr()
|
|
1889
|
+
extra = self.task / ".devlyn/extra.log"
|
|
1890
|
+
extra.write_bytes(b"before\n")
|
|
1891
|
+
saved = self.receipt.parent / "records"
|
|
1892
|
+
shutil.copytree(self.task / ".devlyn", saved / ".devlyn")
|
|
1893
|
+
original = records_manifest(saved)
|
|
1894
|
+
extra.unlink()
|
|
1895
|
+
final = records_manifest(self.task)
|
|
1896
|
+
result, _ = self.complete()
|
|
1897
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1898
|
+
self.assertFalse(self.task.exists())
|
|
1899
|
+
self.assertEqual(records_manifest(saved), original)
|
|
1900
|
+
generation = next(self.receipt.parent.glob("records.*"))
|
|
1901
|
+
self.assertEqual(records_manifest(generation), final)
|
|
1902
|
+
self.assertFalse((generation / ".devlyn/extra.log").exists())
|
|
1903
|
+
|
|
1904
|
+
def test_records_generation_reuse_and_mismatch(self):
|
|
1905
|
+
self.allocate(); self.accept()
|
|
1906
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1907
|
+
self.merge_pr()
|
|
1908
|
+
saved = self.receipt.parent / "records"
|
|
1909
|
+
shutil.copytree(self.task / ".devlyn", saved / ".devlyn")
|
|
1910
|
+
original = records_manifest(saved)
|
|
1911
|
+
(self.task / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
|
|
1912
|
+
final = records_manifest(self.task)
|
|
1913
|
+
digest = hashlib.sha256(json.dumps(final, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
|
|
1914
|
+
generation = self.receipt.parent / ("records." + digest)
|
|
1915
|
+
shutil.copytree(self.task / ".devlyn", generation / ".devlyn")
|
|
1916
|
+
extra = generation / ".devlyn/unexpected"
|
|
1917
|
+
extra.write_bytes(b"keep this copy\n")
|
|
1918
|
+
mismatch = records_manifest(generation)
|
|
1919
|
+
result, _ = self.complete(success=False)
|
|
1920
|
+
self.assertIn(generation.name, result["reason"])
|
|
1846
1921
|
self.assertTrue(self.task.exists())
|
|
1847
|
-
self.
|
|
1848
|
-
self.assertEqual(
|
|
1849
|
-
|
|
1922
|
+
self.assertEqual(records_manifest(generation), mismatch)
|
|
1923
|
+
self.assertEqual(records_manifest(saved), original)
|
|
1924
|
+
extra.unlink() # Owner repairs the mismatched copy before resuming.
|
|
1925
|
+
identity = generation.stat()
|
|
1926
|
+
result, _ = self.complete()
|
|
1927
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1928
|
+
self.assertFalse(self.task.exists())
|
|
1929
|
+
self.assertEqual(records_manifest(saved), original)
|
|
1930
|
+
self.assertEqual(records_manifest(generation), final)
|
|
1931
|
+
self.assertEqual(generation.stat(), identity)
|
|
1932
|
+
|
|
1933
|
+
def test_records_change_after_copy_retains_until_resume(self):
|
|
1934
|
+
from unittest.mock import patch
|
|
1935
|
+
self.allocate(); self.accept()
|
|
1936
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1937
|
+
self.merge_pr()
|
|
1938
|
+
original = records_manifest(self.task)
|
|
1939
|
+
saved = self.receipt.parent / "records"
|
|
1940
|
+
check = removable
|
|
1941
|
+
def changed(receipt, work):
|
|
1942
|
+
check(receipt, work)
|
|
1943
|
+
if saved.exists():
|
|
1944
|
+
(work / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
|
|
1945
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
|
|
1946
|
+
with patch.dict(os.environ, self.env), patch.dict(cleanup.__globals__, {"removable": changed}):
|
|
1947
|
+
with self.assertRaisesRegex(CompletionError, "changed after it was copied; stop its writer and resume"):
|
|
1948
|
+
complete(args)
|
|
1949
|
+
self.assertTrue(self.task.exists())
|
|
1950
|
+
self.assertEqual(records_manifest(saved), original)
|
|
1951
|
+
final = records_manifest(self.task)
|
|
1850
1952
|
result, _ = self.complete()
|
|
1851
1953
|
self.assertEqual(result["status"], "COMPLETE")
|
|
1852
1954
|
self.assertFalse(self.task.exists())
|
|
1955
|
+
self.assertEqual(records_manifest(saved), original)
|
|
1956
|
+
self.assertEqual(records_manifest(next(self.receipt.parent.glob("records.*"))), final)
|
|
1957
|
+
|
|
1958
|
+
def test_records_staged_extra_file_retains_without_publishing(self):
|
|
1959
|
+
from unittest.mock import patch
|
|
1960
|
+
self.allocate(); self.accept()
|
|
1961
|
+
self.complete("--mode", "pr", "--writers-stopped")
|
|
1962
|
+
self.merge_pr()
|
|
1963
|
+
copy = shutil.copytree
|
|
1964
|
+
def changed(source, destination, **kwargs):
|
|
1965
|
+
result = copy(source, destination, **kwargs)
|
|
1966
|
+
(destination / "unexpected").write_bytes(b"late write\n")
|
|
1967
|
+
return result
|
|
1968
|
+
args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
|
|
1969
|
+
with patch.dict(os.environ, self.env), patch.object(shutil, "copytree", changed):
|
|
1970
|
+
with self.assertRaisesRegex(CompletionError, "changed while it was copied; stop its writer and resume"):
|
|
1971
|
+
complete(args)
|
|
1972
|
+
self.assertTrue(self.task.exists())
|
|
1973
|
+
self.assertFalse((self.receipt.parent / "records").exists())
|
|
1974
|
+
self.assertEqual(list(self.receipt.parent.glob(".records-*")), [])
|
|
1853
1975
|
|
|
1854
1976
|
def test_linked_custody_and_interrupted_removal(self):
|
|
1855
1977
|
self.allocate(); self.accept()
|
|
@@ -218,8 +218,10 @@ The receipt directory holds byte-verified `custody/`, `manifest.json` and a guar
|
|
|
218
218
|
before publication; removal rechecks it, the recovery ref and the merge commit on base.
|
|
219
219
|
Ignored build output and other ignored files are disposable under native
|
|
220
220
|
non-force worktree removal. The tree's `.devlyn/` files are byte/mode-verified
|
|
221
|
-
and preserved as `<receipt dir>/records/.devlyn`;
|
|
222
|
-
|
|
221
|
+
and preserved as `<receipt dir>/records/.devlyn`; changes after an earlier copy
|
|
222
|
+
are preserved beside it as `records.<digest>/.devlyn`, never rewriting earlier copies.
|
|
223
|
+
Non-regular files or symlinks there, partial/corrupt custody, an unreadable
|
|
224
|
+
earlier copy or a mismatched `records.<digest>` copy retain the tree for repair.
|
|
223
225
|
|
|
224
226
|
Legacy in-place receipts retire refs only once their task branch is no longer
|
|
225
227
|
checked out; their checkout is never modified. Local ref deletion is guarded by
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.16",
|
|
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": {
|