devlyn-cli 2.9.3 → 2.10.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.
- package/AGENTS.md +3 -3
- package/CLAUDE.md +5 -5
- package/README.md +5 -5
- package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
- package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
- package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
- package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
- package/bin/devlyn.js +108 -15
- package/config/skills/_shared/adapters/README.md +2 -2
- package/config/skills/_shared/adapters/grok.md +99 -0
- package/config/skills/_shared/archive_run.py +35 -1
- package/config/skills/_shared/collect-codex-findings.py +231 -50
- package/config/skills/_shared/engine-doctor.sh +6 -3
- package/config/skills/_shared/engine-preflight.md +12 -5
- package/config/skills/_shared/expected.schema.json +7 -0
- package/config/skills/_shared/grok-anchor-guard.py +61 -0
- package/config/skills/_shared/phase-prompt-render.py +181 -0
- package/config/skills/_shared/resolve-bootstrap.py +630 -0
- package/config/skills/_shared/resolve-stop-hook.py +325 -0
- package/config/skills/_shared/spec-verify-check.py +308 -31
- package/config/skills/_shared/state-phase-write.py +2966 -27
- package/config/skills/_shared/terminal-claim-check.py +537 -0
- package/config/skills/_shared/verify-merge-findings.py +533 -16
- package/config/skills/devlyn:engines/SKILL.md +2 -2
- package/config/skills/devlyn:ideate/SKILL.md +1 -1
- package/config/skills/devlyn:queue/SKILL.md +12 -0
- package/config/skills/devlyn:resolve/SKILL.md +60 -78
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
- package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
- package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
- package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
- package/config/skills/devlyn:resolve/references/phases/verify.md +70 -97
- package/config/skills/devlyn:resolve/references/state-schema.md +26 -19
- package/optional-skills/devlyn:reap/SKILL.md +1 -1
- package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
- package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
- package/package.json +1 -1
- package/scripts/lint-fixtures.sh +4 -1
- package/scripts/lint-skills.sh +269 -77
|
@@ -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())
|