devlyn-cli 3.1.3 → 3.2.1

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.
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env python3
2
+ """Exercise the candidate's owner phases through the real CLI and Git boundary."""
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import runpy
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import unittest
12
+
13
+ SHARED = Path(__file__).resolve().parents[1] / "config/skills/_shared"
14
+ ENV = {k: v for k, v in os.environ.items() if not k.startswith("DEVLYN_INVOCATION_")}
15
+ ENV["PYTHONDONTWRITEBYTECODE"] = "1"
16
+
17
+
18
+ class OwnerPhases(unittest.TestCase):
19
+ def setUp(self):
20
+ self.temp = tempfile.TemporaryDirectory()
21
+ self.addCleanup(self.temp.cleanup)
22
+ self.work = Path(self.temp.name).resolve()
23
+ self.devlyn = self.work / ".devlyn"
24
+ self.devlyn.mkdir()
25
+ self.state_path = self.devlyn / "pipeline.state.json"
26
+ (self.devlyn / "untracked.baseline").write_text("")
27
+ self.git("init", "-q")
28
+ self.git("config", "user.name", "Owner phase test")
29
+ self.git("config", "user.email", "owner@example.invalid")
30
+ (self.work / ".gitignore").write_text(".devlyn/\n")
31
+ (self.work / "source.txt").write_text("original\n")
32
+ self.git("add", ".")
33
+ self.git("commit", "-qm", "fixture")
34
+ self.head = self.git("rev-parse", "HEAD")
35
+ self.save({"version": "3.0", "run_id": "rs-owner-test", "engine": "codex",
36
+ "phases": {}})
37
+
38
+ def git(self, *args):
39
+ return subprocess.check_output(["git", *args], cwd=self.work, text=True).strip()
40
+
41
+ def state(self):
42
+ return json.loads(self.state_path.read_text())
43
+
44
+ def save(self, value):
45
+ self.state_path.write_text(json.dumps(value))
46
+
47
+ def cli(self, phase, *args, error=None):
48
+ before = self.state_path.read_bytes()
49
+ result = subprocess.run(
50
+ [sys.executable, str(SHARED / "state-phase-write.py"), "--devlyn-dir", ".devlyn",
51
+ "--phase", phase, *args], cwd=self.work, env=ENV, capture_output=True, text=True,
52
+ )
53
+ if error:
54
+ self.assertNotEqual(result.returncode, 0, result.stdout)
55
+ self.assertIn(error, result.stderr)
56
+ self.assertEqual(self.state_path.read_bytes(), before)
57
+ else:
58
+ self.assertEqual(result.returncode, 0, result.stderr)
59
+ return result
60
+
61
+ def assert_owner(self, phase):
62
+ entry = self.state()["phases"][phase]
63
+ self.assertEqual(entry["execution_kind"],
64
+ "orchestrator_context" if phase == "plan" else "orchestrator_commands")
65
+ for name in ("engine", "model_requested", "model_effective"):
66
+ self.assertIsNone(entry[name])
67
+ self.assertFalse({"prompt_sha256", "invocation_receipt", "role_argv"} & entry.keys())
68
+ return entry
69
+
70
+ def plan(self):
71
+ self.cli("plan", "spawn", "--round", "0")
72
+ self.assert_owner("plan")
73
+ (self.devlyn / "plan.md").write_text(
74
+ '<!-- devlyn:authorized-surface -->\n# Files\n```json\n'
75
+ '{"authorized_surface":["source.txt"]}\n```\n# Acceptance\nPreserve scope.\n')
76
+
77
+ def test_plan_digest_and_atomic_handoff(self):
78
+ self.plan()
79
+ self.cli("plan", "transition", "--verdict", "PASS", "--next-phase", "implement",
80
+ "--next-round", "0", "--next-engine", "claude")
81
+ entry = self.assert_owner("plan")
82
+ self.assertEqual(entry["output_sha256"],
83
+ hashlib.sha256((self.devlyn / "plan.md").read_bytes()).hexdigest())
84
+ self.cli("plan", "spawn", "--round", "1", "--triggered-by", "plan",
85
+ error="plan-already-in-use")
86
+ (self.devlyn / "plan.md").write_text("widened scope")
87
+ self.cli("implement", "complete", "--verdict", "PASS", error="plan-integrity-mismatch")
88
+
89
+ def test_owner_identity_and_current_round_artifacts(self):
90
+ for phase in ("plan", "cleanup"):
91
+ self.save({"version": "3.0", "run_id": "rs-owner-test", "engine": "codex", "phases": {}})
92
+ spawn = ("spawn", "--round", "0") + (("--pre-sha", self.head) if phase == "cleanup" else ())
93
+ self.cli(phase, *spawn)
94
+ opened = self.state()
95
+ for flag in ("--engine", "--model", "--engine-session-log"):
96
+ self.cli(phase, "complete", "--verdict", "BLOCKED", flag, "false-worker",
97
+ error="cannot claim worker")
98
+ for suffix in ("prompt.0", "worker-session.0.jsonl", "invocation.0.json", "argv.0.json"):
99
+ proof = self.devlyn / f"{phase}.{suffix}"
100
+ proof.symlink_to(self.devlyn / "missing")
101
+ self.cli(phase, "complete", "--verdict", "BLOCKED", error="worker evidence")
102
+ proof.unlink()
103
+ for key, value in (("engine", "codex"), ("prompt_sha256", None),
104
+ ("execution_kind", False), ("execution_kind", {})):
105
+ bad = json.loads(json.dumps(opened))
106
+ bad["phases"][phase][key] = value
107
+ self.save(bad)
108
+ self.cli(phase, "complete", "--verdict", "BLOCKED", error="error:")
109
+ self.save(opened)
110
+
111
+ def test_owner_plan_history_is_bound_and_round_limited(self):
112
+ self.plan()
113
+ self.cli("plan", "complete", "--verdict", "NEEDS_WORK")
114
+ old = self.assert_owner("plan")
115
+ self.cli("plan", "spawn", "--round", "1", "--triggered-by", "plan")
116
+ self.assertEqual(self.state()["phases"]["plan"]["history"][0]["output_sha256"], old["output_sha256"])
117
+ self.cli("plan", "complete", "--verdict", "BLOCKED")
118
+ self.cli("plan", "spawn", "--round", "2", "--triggered-by", "plan", error="plan-respawn-exhausted")
119
+
120
+ def test_cleanup_checks_source_and_preserves_atomicity(self):
121
+ self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
122
+ self.assert_owner("cleanup")
123
+ args = ("transition", "--verdict", "PASS", "--post-sha", self.head,
124
+ "--next-phase", "verify", "--next-round", "0", "--next-engine", "claude")
125
+ (self.work / "source.txt").write_text("unverified change\n")
126
+ self.cli("cleanup", *args, error="owner-cleanup-source-changed")
127
+ self.git("add", "source.txt")
128
+ self.cli("cleanup", *args, error="owner-cleanup-source-changed")
129
+ (self.work / "source.txt").write_text("original\n")
130
+ self.cli("cleanup", *args, error="owner-cleanup-source-changed")
131
+ (self.work / "source.txt").write_text("unverified change\n")
132
+ self.git("commit", "-qm", "unverified")
133
+ self.cli("cleanup", *args, error="owner-cleanup-source-changed")
134
+ # All mutations are in this disposable test fixture.
135
+ self.git("reset", "--hard", self.head)
136
+ self.cli("cleanup", *args)
137
+ self.assertIsNotNone(self.state()["phases"]["verify"]["started_at"])
138
+
139
+ def test_cleanup_repair_reenters_build_with_durability(self):
140
+ self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
141
+ (self.devlyn / "cleanup.findings.jsonl").write_text("")
142
+ self.cli("cleanup", "transition", "--verdict", "FAIL", "--post-sha", self.head,
143
+ "--next-phase", "implement", "--next-round", "1", "--next-triggered-by", "cleanup",
144
+ "--next-engine", "claude")
145
+ self.cli("implement", "transition", "--verdict", "PASS", "--next-phase", "build_gate",
146
+ "--next-round", "1", error="closure-durability")
147
+ self.git("commit", "--allow-empty", "-qm", "chore(pipeline): implement fix round 1")
148
+ self.cli("implement", "durability-enforce", "--round", "1", "--origin-phase", "cleanup")
149
+ for phase in ("cleanup", "verify"):
150
+ self.cli("implement", "transition", "--verdict", "PASS", "--next-phase", phase,
151
+ "--next-round", "1", error="owner-cleanup-repair-route")
152
+ self.cli("implement", "transition", "--verdict", "PASS", "--next-phase", "build_gate", "--next-round", "1")
153
+ self.assert_owner("build_gate")
154
+ fixed_head = self.git("rev-parse", "HEAD")
155
+ (self.devlyn / "spec-verify.results.json").write_text(
156
+ json.dumps({"commands": [], "process_evidence": None}))
157
+ self.cli("build_gate", "transition", "--verdict", "PASS", "--next-phase", "cleanup",
158
+ "--next-round", "1", "--next-pre-sha", fixed_head)
159
+ (self.devlyn / "cleanup.findings.jsonl").write_text("\n")
160
+ self.cli("cleanup", "transition", "--verdict", "PASS", "--post-sha", fixed_head,
161
+ "--next-phase", "verify", "--next-round", "1", "--next-engine", "claude")
162
+
163
+ def test_cleanup_repair_bypass_still_requires_durability(self):
164
+ self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
165
+ (self.devlyn / "cleanup.findings.jsonl").write_text("")
166
+ self.cli("cleanup", "transition", "--verdict", "FAIL", "--post-sha", self.head,
167
+ "--next-phase", "implement", "--next-round", "1", "--next-triggered-by", "cleanup",
168
+ "--next-engine", "claude")
169
+ self.git("commit", "--allow-empty", "-qm", "chore(pipeline): implement fix round 1")
170
+ pending = self.state()
171
+ for bypasses, phase in (([], "build_gate"), (["cleanup"], "build_gate"),
172
+ (["build-gate"], "cleanup"), (["build-gate", "cleanup"], "verify")):
173
+ with self.subTest(bypasses=bypasses):
174
+ (self.devlyn / "closure-durability.round-1.json").unlink(missing_ok=True)
175
+ self.save(dict(pending, bypasses=bypasses))
176
+ for wrong_round in ("0", "2"):
177
+ self.cli("implement", "transition", "--verdict", "PASS", "--next-phase", phase,
178
+ "--next-round", wrong_round, error="owner-cleanup-repair-route")
179
+ args = ("transition", "--verdict", "PASS", "--next-phase", phase,
180
+ "--next-round", "1", "--next-pre-sha", self.git("rev-parse", "HEAD"))
181
+ self.cli("implement", *args, error="closure-durability")
182
+ self.cli("implement", "durability-enforce", "--round", "1", "--origin-phase", "cleanup")
183
+ self.cli("implement", *args)
184
+
185
+ def test_historical_cleanup_cannot_take_owner_repair_edge(self):
186
+ self.cli("cleanup", "spawn", "--round", "0", "--engine", "claude", "--pre-sha", self.head)
187
+ for verdict in ("PASS", "FAIL"):
188
+ self.cli("cleanup", "transition", "--verdict", verdict,
189
+ "--next-phase", "implement", "--next-round", "1", "--next-triggered-by", "cleanup",
190
+ "--next-engine", "claude", error="owner-cleanup-failed")
191
+ self.cli("cleanup", "transition", "--verdict", "PASS", "--post-sha", self.head,
192
+ "--next-phase", "verify", "--next-round", "0", "--next-engine", "claude")
193
+
194
+ def test_dirty_cleanup_can_fail_honestly_but_cannot_verify(self):
195
+ self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
196
+ opened = self.state_path.read_bytes()
197
+ (self.work / "source.txt").write_text("BUILD left a tracked change\n")
198
+ self.cli("cleanup", "transition", "--verdict", "FAIL", "--post-sha", self.head,
199
+ "--next-phase", "verify", "--next-round", "0", error="repair before VERIFY")
200
+ self.cli("cleanup", "complete", "--verdict", "BLOCKED", "--post-sha", self.head)
201
+ self.assertEqual(self.state()["phases"]["cleanup"]["verdict"], "BLOCKED")
202
+ self.state_path.write_bytes(opened)
203
+ self.cli("cleanup", "transition", "--verdict", "FAIL", "--post-sha", self.head,
204
+ "--next-phase", "implement", "--next-round", "1", "--next-triggered-by", "cleanup",
205
+ "--next-engine", "claude")
206
+ self.assertEqual((self.work / "source.txt").read_text(), "BUILD left a tracked change\n")
207
+
208
+ def test_final_untracked_delta_cannot_bypass_scope(self):
209
+ (self.work / "user-existing.txt").write_text("preserve")
210
+ (self.devlyn / "untracked.baseline").write_text("user-existing.txt\n")
211
+ self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
212
+ (self.work / "outside-plan.txt").write_text("leak")
213
+ self.cli("cleanup", "complete", "--verdict", "PASS", "--post-sha", self.head,
214
+ error="owner-cleanup-untracked")
215
+ (self.work / "outside-plan.txt").unlink()
216
+ (self.devlyn / "untracked.baseline").unlink()
217
+ self.cli("cleanup", "complete", "--verdict", "PASS", "--post-sha", self.head,
218
+ error="owner-cleanup-untracked")
219
+ (self.devlyn / "untracked.baseline").write_text("user-existing.txt\n")
220
+ self.cli("cleanup", "complete", "--verdict", "PASS", "--post-sha", self.head)
221
+ self.assertEqual((self.work / "user-existing.txt").read_text(), "preserve")
222
+
223
+ def test_owner_cleanup_does_not_replace_selected_worker(self):
224
+ helper = runpy.run_path(str(SHARED / "state-phase-write.py"))
225
+ (self.devlyn / "engines.json").write_text(json.dumps({"roles": {
226
+ "worker": {"engine": "codex", "model": "gpt-6-astra", "effort": "high"}}}))
227
+ state = self.state()
228
+ frozen = helper["freeze_roles"](state, self.work, "codex")
229
+ self.save(state)
230
+ self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
231
+ self.assert_owner("cleanup")
232
+ self.assertEqual(self.state()["role_resolution"], frozen)
233
+ self.cli("implement", "spawn", "--round", "0", "--engine", "claude",
234
+ error="role-selection-mismatch")
235
+
236
+
237
+ if __name__ == "__main__":
238
+ unittest.main(verbosity=2)