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.
Files changed (40) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +5 -5
  3. package/README.md +5 -5
  4. package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
  5. package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
  6. package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
  7. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
  8. package/bin/devlyn.js +108 -15
  9. package/config/skills/_shared/adapters/README.md +2 -2
  10. package/config/skills/_shared/adapters/grok.md +99 -0
  11. package/config/skills/_shared/archive_run.py +35 -1
  12. package/config/skills/_shared/collect-codex-findings.py +231 -50
  13. package/config/skills/_shared/engine-doctor.sh +6 -3
  14. package/config/skills/_shared/engine-preflight.md +12 -5
  15. package/config/skills/_shared/expected.schema.json +7 -0
  16. package/config/skills/_shared/grok-anchor-guard.py +61 -0
  17. package/config/skills/_shared/phase-prompt-render.py +181 -0
  18. package/config/skills/_shared/resolve-bootstrap.py +630 -0
  19. package/config/skills/_shared/resolve-stop-hook.py +325 -0
  20. package/config/skills/_shared/spec-verify-check.py +308 -31
  21. package/config/skills/_shared/state-phase-write.py +2966 -27
  22. package/config/skills/_shared/terminal-claim-check.py +537 -0
  23. package/config/skills/_shared/verify-merge-findings.py +533 -16
  24. package/config/skills/devlyn:engines/SKILL.md +2 -2
  25. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  26. package/config/skills/devlyn:queue/SKILL.md +12 -0
  27. package/config/skills/devlyn:resolve/SKILL.md +60 -78
  28. package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
  29. package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
  30. package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
  31. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
  32. package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
  33. package/config/skills/devlyn:resolve/references/phases/verify.md +70 -97
  34. package/config/skills/devlyn:resolve/references/state-schema.md +26 -19
  35. package/optional-skills/devlyn:reap/SKILL.md +1 -1
  36. package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
  37. package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
  38. package/package.json +1 -1
  39. package/scripts/lint-fixtures.sh +4 -1
  40. package/scripts/lint-skills.sh +269 -77
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """Render exact phase-prompt bytes and print their SHA-256 digest."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import os
8
+ import pathlib
9
+ import re
10
+ import tempfile
11
+
12
+
13
+ EXCLUDED_ADAPTER_SECTION = re.compile(
14
+ rb"^## (?:Role eligibility|Invocation)(?:\r?\n|\Z).*?(?=^## |\Z)",
15
+ re.MULTILINE | re.DOTALL,
16
+ )
17
+ EXCLUDED_ADAPTER_HEADING = re.compile(
18
+ rb"^## (?:Role eligibility|Invocation)\r?$", re.MULTILINE
19
+ )
20
+
21
+
22
+ def project_adapter(content: bytes) -> bytes:
23
+ projected = EXCLUDED_ADAPTER_SECTION.sub(b"", content)
24
+ assert EXCLUDED_ADAPTER_HEADING.search(projected) is None
25
+ return projected
26
+
27
+
28
+ def validate_plan_context(task_context: pathlib.Path, content: bytes) -> None:
29
+ if task_context.name != "plan.task-context":
30
+ return
31
+ working_directory = pathlib.Path.cwd()
32
+ expected = (
33
+ b"Working directory: "
34
+ + os.fsencode(working_directory)
35
+ + b"\nPlan output: "
36
+ + os.fsencode(working_directory / ".devlyn" / "plan.md")
37
+ + b"\n"
38
+ )
39
+ if not content.startswith(expected):
40
+ raise SystemExit("error: invalid PLAN task-context header")
41
+
42
+
43
+ def render_prompt(
44
+ adapter: pathlib.Path,
45
+ canonical_body: pathlib.Path,
46
+ task_context: pathlib.Path,
47
+ output: pathlib.Path,
48
+ ) -> str:
49
+ try:
50
+ adapter_bytes = adapter.read_bytes()
51
+ body_bytes = canonical_body.read_bytes()
52
+ context_bytes = task_context.read_bytes()
53
+ except OSError as exc:
54
+ raise SystemExit(f"error: prompt input unreadable: {exc}") from exc
55
+ projected_adapter = project_adapter(adapter_bytes)
56
+ validate_plan_context(task_context, context_bytes)
57
+ rendered = projected_adapter + body_bytes + context_bytes
58
+ rendered = rendered.rstrip(b"\n")
59
+ if not output.parent.is_dir():
60
+ raise SystemExit(f"error: prompt output parent is not a directory: {output.parent}")
61
+ fd, temporary = tempfile.mkstemp(dir=output.parent, prefix=output.name + ".tmp.")
62
+ temporary_path = pathlib.Path(temporary)
63
+ try:
64
+ with os.fdopen(fd, "wb") as handle:
65
+ handle.write(rendered)
66
+ handle.flush()
67
+ os.fsync(handle.fileno())
68
+ temporary_path.replace(output)
69
+ except BaseException:
70
+ temporary_path.unlink(missing_ok=True)
71
+ raise
72
+ return hashlib.sha256(rendered).hexdigest()
73
+
74
+
75
+ def self_test() -> int:
76
+ with tempfile.TemporaryDirectory(prefix="phase-prompt-render-") as raw:
77
+ root = pathlib.Path(raw)
78
+ adapter = root / "adapter.md"
79
+ body = root / "plan.md"
80
+ context = root / "plan.task-context"
81
+ output = root / ".devlyn" / "plan.prompt"
82
+ output.parent.mkdir()
83
+ original_directory = pathlib.Path.cwd()
84
+ os.chdir(root)
85
+ try:
86
+ working_directory = pathlib.Path.cwd()
87
+ adapter_bytes = (
88
+ b"# Claude adapter\r\n"
89
+ b"## Identity\r\nkept-identity-\xff\r\n"
90
+ b"## Role eligibility\nremove-role\r\n"
91
+ b"## Output discipline\r\nkept-output-\x80\n"
92
+ b"## Invocation\r\nremove-invocation\n"
93
+ b"## Anti-patterns\nkept-tail-without-newline"
94
+ )
95
+ projected_adapter = (
96
+ b"# Claude adapter\r\n"
97
+ b"## Identity\r\nkept-identity-\xff\r\n"
98
+ b"## Output discipline\r\nkept-output-\x80\n"
99
+ b"## Anti-patterns\nkept-tail-without-newline"
100
+ )
101
+ for metadata_free in (
102
+ b"# Codex adapter\r\n## Identity\nkept-\xfe",
103
+ b"# omp adapter\n## Output discipline\r\nkept\x81",
104
+ ):
105
+ assert project_adapter(metadata_free) == metadata_free
106
+
107
+ body_bytes = b"# PHASE 1 \xe2\x80\x94 PLAN\n"
108
+ context_bytes = (
109
+ b"Working directory: "
110
+ + os.fsencode(working_directory)
111
+ + b"\nPlan output: "
112
+ + os.fsencode(working_directory / ".devlyn" / "plan.md")
113
+ + b"\ncontext-without-newline"
114
+ )
115
+ for path, content in (
116
+ (adapter, adapter_bytes),
117
+ (body, body_bytes),
118
+ (context, context_bytes),
119
+ ):
120
+ path.write_bytes(content)
121
+ expected = projected_adapter + body_bytes + context_bytes
122
+ digest = render_prompt(adapter, body, context, output)
123
+ assert output.read_bytes() == expected
124
+ assert digest == hashlib.sha256(expected).hexdigest()
125
+ assert render_prompt(adapter, body, context, output) == digest
126
+ assert output.read_bytes() == expected
127
+
128
+ context_prefix = context_bytes.removesuffix(b"context-without-newline")
129
+ for terminal_lfs in (b"", b"\n", b"\n\n"):
130
+ case_context = context_prefix + b"context-terminal-lf-case" + terminal_lfs
131
+ context.write_bytes(case_context)
132
+ expected = (projected_adapter + body_bytes + case_context).rstrip(b"\n")
133
+ digest = render_prompt(adapter, body, context, output)
134
+ written = output.read_bytes()
135
+ assert not written.endswith(b"\n")
136
+ assert digest == hashlib.sha256(written).hexdigest()
137
+ assert written == expected
138
+
139
+ invalid_contexts = (
140
+ b"context-without-header",
141
+ b"Working directory: relative\nPlan output: relative/.devlyn/plan.md\n",
142
+ b"Working directory: /mismatch\nPlan output: /mismatch/.devlyn/plan.md\n",
143
+ )
144
+ for invalid_context in invalid_contexts:
145
+ context.write_bytes(invalid_context)
146
+ output.write_bytes(b"unchanged")
147
+ try:
148
+ render_prompt(adapter, body, context, output)
149
+ except SystemExit:
150
+ pass
151
+ else:
152
+ raise AssertionError("invalid PLAN task-context header accepted")
153
+ assert output.read_bytes() == b"unchanged"
154
+ finally:
155
+ os.chdir(original_directory)
156
+ print("SELFTEST PASS: projected exact bytes + PLAN context validation")
157
+ return 0
158
+
159
+
160
+ def main() -> int:
161
+ parser = argparse.ArgumentParser(description=__doc__)
162
+ parser.add_argument("--adapter", type=pathlib.Path)
163
+ parser.add_argument("--canonical-body", type=pathlib.Path)
164
+ parser.add_argument("--task-context", type=pathlib.Path)
165
+ parser.add_argument("--output", type=pathlib.Path)
166
+ parser.add_argument("--self-test", action="store_true")
167
+ args = parser.parse_args()
168
+ if args.self_test:
169
+ if any((args.adapter, args.canonical_body, args.task_context, args.output)):
170
+ parser.error("render paths are not allowed with --self-test")
171
+ return self_test()
172
+ if not all((args.adapter, args.canonical_body, args.task_context, args.output)):
173
+ parser.error(
174
+ "--adapter, --canonical-body, --task-context, and --output are required"
175
+ )
176
+ print(render_prompt(args.adapter, args.canonical_body, args.task_context, args.output))
177
+ return 0
178
+
179
+
180
+ if __name__ == "__main__":
181
+ raise SystemExit(main())