devlyn-cli 2.9.3 → 2.10.0

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 (28) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +4 -4
  3. package/README.md +4 -4
  4. package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
  5. package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
  6. package/bin/devlyn.js +55 -7
  7. package/config/skills/_shared/adapters/README.md +2 -2
  8. package/config/skills/_shared/adapters/grok.md +77 -0
  9. package/config/skills/_shared/archive_run.py +35 -1
  10. package/config/skills/_shared/collect-codex-findings.py +15 -10
  11. package/config/skills/_shared/engine-doctor.sh +6 -3
  12. package/config/skills/_shared/engine-preflight.md +12 -5
  13. package/config/skills/_shared/resolve-bootstrap.py +630 -0
  14. package/config/skills/_shared/resolve-stop-hook.py +325 -0
  15. package/config/skills/_shared/state-phase-write.py +2264 -13
  16. package/config/skills/_shared/terminal-claim-check.py +537 -0
  17. package/config/skills/_shared/verify-merge-findings.py +363 -16
  18. package/config/skills/devlyn:engines/SKILL.md +1 -1
  19. package/config/skills/devlyn:queue/SKILL.md +12 -0
  20. package/config/skills/devlyn:resolve/SKILL.md +54 -73
  21. package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
  22. package/config/skills/devlyn:resolve/references/phases/verify.md +65 -96
  23. package/config/skills/devlyn:resolve/references/state-schema.md +25 -18
  24. package/optional-skills/devlyn:reap/SKILL.md +1 -1
  25. package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
  26. package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
  27. package/package.json +1 -1
  28. package/scripts/lint-skills.sh +131 -67
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env python3
2
+ """Block a Claude Stop only for same-session incomplete devlyn state."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import fnmatch
7
+ import hashlib
8
+ import importlib.util
9
+ import json
10
+ import os
11
+ import pathlib
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ from types import ModuleType
17
+ from typing import Any
18
+
19
+ # Allow paths must make zero project-filesystem mutations; without this the
20
+ # importlib loads below write a __pycache__ directory next to the installed
21
+ # hook on every invocation (caught live by P-0078-O control (a)).
22
+ sys.dont_write_bytecode = True
23
+
24
+
25
+ RECEIPT_PATTERN = "resolve-stop-hook.*.json"
26
+
27
+
28
+ def load_module(path: pathlib.Path, name: str) -> ModuleType:
29
+ spec = importlib.util.spec_from_file_location(name, path)
30
+ if spec is None or spec.loader is None:
31
+ raise RuntimeError(f"module cannot be loaded: {path}")
32
+ module = importlib.util.module_from_spec(spec)
33
+ sys.modules[spec.name] = module
34
+ spec.loader.exec_module(module)
35
+ return module
36
+
37
+
38
+ def allow(reason: str) -> int:
39
+ print(f"devlyn resolve Stop allowed: {reason}", file=sys.stderr)
40
+ return 0
41
+
42
+
43
+ def parse_hook_input(raw: bytes) -> dict[str, Any]:
44
+ value = json.loads(raw.decode("utf-8"))
45
+ if not isinstance(value, dict):
46
+ raise ValueError("hook input must be a JSON object")
47
+ if value.get("hook_event_name") != "Stop":
48
+ raise ValueError("hook_event_name must be Stop")
49
+ if not isinstance(value.get("stop_hook_active"), bool):
50
+ raise ValueError("stop_hook_active must be boolean")
51
+ return value
52
+
53
+
54
+ def canonical_root(hook_input: dict[str, Any]) -> pathlib.Path | None:
55
+ cwd = hook_input.get("cwd")
56
+ if not isinstance(cwd, str) or not cwd:
57
+ return None
58
+ try:
59
+ root = pathlib.Path(cwd).resolve(strict=True)
60
+ except OSError:
61
+ return None
62
+ return root if root.is_dir() else None
63
+
64
+
65
+ def hook_session_id(hook_input: dict[str, Any]) -> object | None:
66
+ stdin_session = hook_input.get("session_id")
67
+ if stdin_session is not None:
68
+ return stdin_session
69
+ return os.environ.get("CLAUDE_CODE_SESSION_ID")
70
+
71
+
72
+ def write_block_receipt(
73
+ root: pathlib.Path,
74
+ classification: object,
75
+ hook_input: dict[str, Any],
76
+ session_id: object,
77
+ ) -> pathlib.Path:
78
+ run_id = getattr(classification, "run_id")
79
+ observed_ns = time.monotonic_ns()
80
+ receipt = root / ".devlyn" / (
81
+ f"resolve-stop-hook.{run_id}.{os.getpid()}.{observed_ns}.json"
82
+ )
83
+ record = {
84
+ "classifier_status": getattr(classification, "status"),
85
+ "hook_event_name": "Stop",
86
+ "observed_ns": observed_ns,
87
+ "run_id": run_id,
88
+ "session_id": session_id,
89
+ "stop_hook_active": hook_input["stop_hook_active"],
90
+ }
91
+ data = json.dumps(record, sort_keys=True, separators=(",", ":")).encode() + b"\n"
92
+ temporary = tempfile.NamedTemporaryFile(
93
+ mode="wb", prefix=".resolve-stop-hook.", dir=receipt.parent, delete=False
94
+ )
95
+ temp_path = pathlib.Path(temporary.name)
96
+ try:
97
+ with temporary as stream:
98
+ stream.write(data)
99
+ stream.flush()
100
+ os.fsync(stream.fileno())
101
+ os.replace(temp_path, receipt)
102
+ except Exception:
103
+ temp_path.unlink(missing_ok=True)
104
+ raise
105
+ return receipt
106
+
107
+
108
+ def run_hook() -> int:
109
+ try:
110
+ hook_input = parse_hook_input(sys.stdin.buffer.read())
111
+ except (OSError, UnicodeError, ValueError) as exc:
112
+ return allow(f"hook input unavailable ({type(exc).__name__})")
113
+
114
+ root = canonical_root(hook_input)
115
+ if root is None:
116
+ return allow("canonical project root unavailable")
117
+
118
+ state_path = root / ".devlyn" / "pipeline.state.json"
119
+ try:
120
+ state_bytes = state_path.read_bytes()
121
+ except FileNotFoundError:
122
+ return allow("active pipeline state absent")
123
+ except OSError as exc:
124
+ return allow(f"active pipeline state unreadable ({type(exc).__name__})")
125
+
126
+ try:
127
+ classifier = load_module(
128
+ pathlib.Path(__file__).with_name("terminal-claim-check.py"),
129
+ "devlyn_terminal_claim_check",
130
+ )
131
+ classification, state = classifier.classify_active_state(root, state_bytes)
132
+ except (AttributeError, ImportError, OSError, RuntimeError, SyntaxError, TypeError, ValueError) as exc:
133
+ return allow(f"classifier unavailable ({type(exc).__name__})")
134
+
135
+ if classification.status == "MALFORMED" or state is None:
136
+ return allow(f"active pipeline state {classification.status}")
137
+ if not classification.status.startswith("INCOMPLETE:"):
138
+ return allow(f"active pipeline state {classification.status}")
139
+
140
+ session_id = hook_session_id(hook_input)
141
+ state_session_id = state.get("session_id")
142
+ if state_session_id is None:
143
+ return allow("active pipeline state has no session owner")
144
+ if session_id is None:
145
+ return allow("hook session id unavailable")
146
+ if state_session_id != session_id:
147
+ return allow("active pipeline state belongs to another session")
148
+
149
+ try:
150
+ write_block_receipt(root, classification, hook_input, session_id)
151
+ except (OSError, TypeError, ValueError) as exc:
152
+ return allow(f"block receipt unavailable ({type(exc).__name__})")
153
+
154
+ shared = pathlib.Path(__file__).resolve().parent
155
+ print(
156
+ f"devlyn resolve Stop blocked: {classification.status}. Repair path: complete "
157
+ f"the open phase via {shared / 'state-phase-write.py'}; if completion is "
158
+ f"impossible, record an honest BLOCKED terminal via {shared / 'state-phase-write.py'} "
159
+ f"and archive via {shared / 'archive_run.py'}.",
160
+ file=sys.stderr,
161
+ )
162
+ return 2
163
+
164
+
165
+ def tree_snapshot(root: pathlib.Path) -> dict[str, str]:
166
+ snapshot: dict[str, str] = {}
167
+ for path in sorted(root.rglob("*")):
168
+ relative = path.relative_to(root).as_posix()
169
+ if path.is_symlink():
170
+ snapshot[f"L:{relative}"] = os.readlink(path)
171
+ elif path.is_dir():
172
+ snapshot[f"D:{relative}"] = ""
173
+ else:
174
+ snapshot[f"F:{relative}"] = hashlib.sha256(path.read_bytes()).hexdigest()
175
+ return snapshot
176
+
177
+
178
+ def self_test() -> int:
179
+ repo = pathlib.Path(__file__).resolve().parents[3]
180
+ k2a_source = repo / (
181
+ "benchmark/ceiling/results/iter0077-probe-a/"
182
+ "DR-auth-signature-f12-webhook/A1/devlyn-snapshot/pipeline.state.json"
183
+ )
184
+ clean_source = repo / (
185
+ "benchmark/ceiling/results/nodeg-20260719g/"
186
+ "DR-atomic-state-f11-batch-import/A1/devlyn-snapshot/runs/"
187
+ "rs-20260719T121412Z-6408dc3d43c8/pipeline.state.json"
188
+ )
189
+ archive_module = load_module(
190
+ pathlib.Path(__file__).with_name("archive_run.py"), "devlyn_archive_run"
191
+ )
192
+ assert k2a_source.is_file() and clean_source.is_file()
193
+ scratch = pathlib.Path(tempfile.mkdtemp(prefix="devlyn-0078-stop-hook-"))
194
+ session = "session-0078"
195
+
196
+ def state_from(source: pathlib.Path, owner: object) -> tuple[bytes, str]:
197
+ state = json.loads(source.read_bytes())
198
+ state["session_id"] = owner
199
+ return json.dumps(state, indent=2, sort_keys=True).encode() + b"\n", state["run_id"]
200
+
201
+ def stage_active(root: pathlib.Path, source: pathlib.Path, owner: object) -> pathlib.Path:
202
+ raw, _ = state_from(source, owner)
203
+ state_path = root / ".devlyn" / "pipeline.state.json"
204
+ state_path.parent.mkdir(parents=True)
205
+ state_path.write_bytes(raw)
206
+ return state_path
207
+
208
+ def stage_archive(root: pathlib.Path, source: pathlib.Path, owner: object) -> None:
209
+ raw, run_id = state_from(source, owner)
210
+ target = root / ".devlyn" / "runs" / run_id / "pipeline.state.json"
211
+ target.parent.mkdir(parents=True, exist_ok=True)
212
+ target.write_bytes(raw)
213
+
214
+ def invoke(
215
+ root: pathlib.Path,
216
+ *,
217
+ stdin_session: object = session,
218
+ env_session: str | None = None,
219
+ raw_input: bytes | None = None,
220
+ active: bool = False,
221
+ ) -> subprocess.CompletedProcess[bytes]:
222
+ payload = {
223
+ "cwd": str(root),
224
+ "hook_event_name": "Stop",
225
+ "session_id": stdin_session,
226
+ "stop_hook_active": active,
227
+ }
228
+ env = os.environ.copy()
229
+ env.pop("CLAUDE_CODE_SESSION_ID", None)
230
+ if env_session is not None:
231
+ env["CLAUDE_CODE_SESSION_ID"] = env_session
232
+ return subprocess.run(
233
+ [sys.executable, str(pathlib.Path(__file__).resolve())],
234
+ input=json.dumps(payload).encode() if raw_input is None else raw_input,
235
+ stdout=subprocess.PIPE,
236
+ stderr=subprocess.PIPE,
237
+ env=env,
238
+ check=False,
239
+ )
240
+
241
+ block = scratch / "block-k2a"
242
+ block.mkdir()
243
+ block_state = stage_active(block, k2a_source, session)
244
+ before_sha = hashlib.sha256(block_state.read_bytes()).hexdigest()
245
+ blocked = invoke(block, active=True)
246
+ receipts = list((block / ".devlyn").glob(RECEIPT_PATTERN))
247
+ assert blocked.returncode == 2 and b"state-phase-write.py" in blocked.stderr
248
+ assert b"archive_run.py" in blocked.stderr
249
+ assert hashlib.sha256(block_state.read_bytes()).hexdigest() == before_sha
250
+ assert len(receipts) == 1
251
+ assert any(fnmatch.fnmatch(receipts[0].name, pattern) for pattern in archive_module.PER_RUN_PATTERNS)
252
+ assert json.loads(receipts[0].read_text())["stop_hook_active"] is True
253
+
254
+ absent = scratch / "control-a-absent"
255
+ absent.mkdir()
256
+ archived_clean = scratch / "control-b-archived-clean"
257
+ archived_clean.mkdir()
258
+ stage_archive(archived_clean, clean_source, session)
259
+ foreign = scratch / "control-c-foreign-active"
260
+ foreign.mkdir()
261
+ stage_active(foreign, k2a_source, "foreign-session")
262
+ archive_only = scratch / "control-d-archive-only-foreign"
263
+ archive_only.mkdir()
264
+ stage_active(archive_only, clean_source, session)
265
+ stage_archive(archive_only, clean_source, session)
266
+ stage_archive(archive_only, k2a_source, "foreign-session")
267
+ for root in (absent, archived_clean, foreign, archive_only):
268
+ before = tree_snapshot(root)
269
+ result = invoke(root)
270
+ assert result.returncode == 0 and tree_snapshot(root) == before
271
+ assert not list(root.rglob(RECEIPT_PATTERN))
272
+ assert not (absent / ".devlyn").exists()
273
+
274
+ null_owner = scratch / "null-owner"
275
+ null_owner.mkdir()
276
+ stage_active(null_owner, k2a_source, None)
277
+ before = tree_snapshot(null_owner)
278
+ assert invoke(null_owner).returncode == 0 and tree_snapshot(null_owner) == before
279
+
280
+ malformed = scratch / "malformed"
281
+ malformed.mkdir()
282
+ malformed_state = malformed / ".devlyn" / "pipeline.state.json"
283
+ malformed_state.parent.mkdir()
284
+ malformed_state.write_bytes(k2a_source.read_bytes()[:-1])
285
+ before = tree_snapshot(malformed)
286
+ assert invoke(malformed).returncode == 0 and tree_snapshot(malformed) == before
287
+
288
+ bad_stdin = scratch / "unparsable-stdin"
289
+ bad_stdin.mkdir()
290
+ stage_active(bad_stdin, k2a_source, session)
291
+ before = tree_snapshot(bad_stdin)
292
+ assert invoke(bad_stdin, raw_input=b"{").returncode == 0
293
+ assert tree_snapshot(bad_stdin) == before
294
+
295
+ env_fallback = scratch / "env-fallback"
296
+ env_fallback.mkdir()
297
+ stage_active(env_fallback, k2a_source, session)
298
+ assert invoke(env_fallback, stdin_session=None, env_session=session).returncode == 2
299
+ stdin_preferred = scratch / "stdin-preferred"
300
+ stdin_preferred.mkdir()
301
+ stage_active(stdin_preferred, k2a_source, session)
302
+ before = tree_snapshot(stdin_preferred)
303
+ assert invoke(
304
+ stdin_preferred, stdin_session="foreign-session", env_session=session
305
+ ).returncode == 0
306
+ assert tree_snapshot(stdin_preferred) == before
307
+
308
+ print(f"resolve-stop-hook self-test: PASS (real receipts; scratch={scratch})")
309
+ return 0
310
+
311
+
312
+ def main(argv: list[str] | None = None) -> int:
313
+ args = sys.argv[1:] if argv is None else argv
314
+ if args == ["--self-test"]:
315
+ return self_test()
316
+ if args:
317
+ return allow("unexpected hook arguments")
318
+ try:
319
+ return run_hook()
320
+ except Exception as exc:
321
+ return allow(f"hook internal error ({type(exc).__name__})")
322
+
323
+
324
+ if __name__ == "__main__":
325
+ raise SystemExit(main())