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