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.
- package/AGENTS.md +10 -94
- package/CLAUDE.md +7 -160
- package/LICENSE +21 -0
- package/README.md +65 -110
- package/bin/devlyn.js +95 -116
- package/bin/instruction-templates.json +43 -2
- package/bin/instructions.js +29 -8
- package/config/skills/_shared/adapters/claude.md +8 -85
- package/config/skills/_shared/adapters/codex.md +14 -34
- package/config/skills/_shared/adapters/omp.md +1 -11
- package/config/skills/_shared/codex-monitored.sh +4 -54
- package/config/skills/_shared/engine-doctor.sh +18 -58
- package/config/skills/_shared/engine-preflight.md +15 -47
- package/config/skills/_shared/expected-contract.py +242 -0
- package/config/skills/_shared/expected.schema.json +10 -95
- package/config/skills/_shared/invocation-receipt.py +26 -757
- package/config/skills/_shared/platform-support.py +37 -28
- package/config/skills/_shared/role-config.py +100 -421
- package/config/skills/_shared/runtime-principles.md +17 -93
- package/config/skills/_shared/task-complete.py +490 -285
- package/config/skills/_shared/task-completion.md +235 -0
- package/config/skills/devlyn-engines/SKILL.md +9 -11
- package/config/skills/devlyn-ideate/SKILL.md +57 -107
- package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
- package/config/skills/devlyn-ideate/references/loop.md +110 -0
- package/config/skills/devlyn-ideate/references/package-format.md +128 -0
- package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
- package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
- package/package.json +2 -3
- package/config/commit-conventions.md +0 -28
- package/config/skills/_shared/adapters/README.md +0 -74
- package/config/skills/_shared/adapters/grok.md +0 -100
- package/config/skills/_shared/archive_run.py +0 -969
- package/config/skills/_shared/codex-config.md +0 -81
- package/config/skills/_shared/collect-codex-findings.py +0 -505
- package/config/skills/_shared/finish-gate.py +0 -521
- package/config/skills/_shared/grok-anchor-guard.py +0 -78
- package/config/skills/_shared/judge-output-parser.py +0 -200
- package/config/skills/_shared/judge-role-evidence.py +0 -320
- package/config/skills/_shared/phase-prompt-render.py +0 -183
- package/config/skills/_shared/process-evidence.py +0 -926
- package/config/skills/_shared/resolve-bootstrap.py +0 -1410
- package/config/skills/_shared/resolve-stop-hook.py +0 -359
- package/config/skills/_shared/run-bounded.py +0 -58
- package/config/skills/_shared/spec-verify-check.py +0 -5083
- package/config/skills/_shared/state-phase-write.py +0 -5989
- package/config/skills/_shared/terminal-claim-check.py +0 -777
- package/config/skills/_shared/verify-merge-findings.py +0 -3528
- package/config/skills/devlyn-design-ui/SKILL.md +0 -650
- package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
- package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
- package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
- package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
- package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
- package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
- package/config/skills/devlyn-queue/SKILL.md +0 -66
- package/config/skills/devlyn-queue/scripts/append.py +0 -94
- package/config/skills/devlyn-resolve/SKILL.md +0 -379
- package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
- package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
- package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
- package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
- package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
- package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
- package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
- package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
- package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
- package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
- package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
- package/config/templates/prompt-templates.md +0 -71
- package/config/templates/template-feature.spec.md +0 -255
- package/config/templates/template-product-spec.md +0 -680
|
@@ -1,521 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Deterministic PHASE 6 final-diff gate for /devlyn-resolve."""
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import runpy
|
|
6
|
-
import argparse
|
|
7
|
-
import importlib.util
|
|
8
|
-
import json
|
|
9
|
-
import pathlib
|
|
10
|
-
import shutil
|
|
11
|
-
import subprocess
|
|
12
|
-
import sys
|
|
13
|
-
import tempfile
|
|
14
|
-
|
|
15
|
-
sys.dont_write_bytecode = True
|
|
16
|
-
|
|
17
|
-
FINDINGS_NAME = "finish-gate.findings.jsonl"
|
|
18
|
-
SUMMARY_NAME = "finish-gate.summary.json"
|
|
19
|
-
PHASE = "finish_gate"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class Malformed(Exception):
|
|
23
|
-
def __init__(self, message: str, file_ref: str = ".devlyn/pipeline.state.json"):
|
|
24
|
-
super().__init__(message)
|
|
25
|
-
self.file_ref = file_ref
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def load_spec_verify():
|
|
29
|
-
helper = pathlib.Path(__file__).with_name("spec-verify-check.py")
|
|
30
|
-
spec = importlib.util.spec_from_file_location("devlyn_spec_verify_check", helper)
|
|
31
|
-
if spec is None or spec.loader is None:
|
|
32
|
-
raise RuntimeError(f"cannot load {helper}")
|
|
33
|
-
module = importlib.util.module_from_spec(spec)
|
|
34
|
-
spec.loader.exec_module(module)
|
|
35
|
-
return module
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
SPEC_VERIFY = load_spec_verify()
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def git(work: pathlib.Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
42
|
-
return subprocess.run(
|
|
43
|
-
["git", *args],
|
|
44
|
-
cwd=str(work),
|
|
45
|
-
capture_output=True,
|
|
46
|
-
text=True,
|
|
47
|
-
encoding="utf-8",
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def git_check(work: pathlib.Path, *args: str) -> str:
|
|
52
|
-
proc = git(work, *args)
|
|
53
|
-
if proc.returncode != 0:
|
|
54
|
-
raise AssertionError(proc.stderr or proc.stdout or f"git {' '.join(args)} failed")
|
|
55
|
-
return proc.stdout.strip()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def read_state(path: pathlib.Path) -> dict:
|
|
59
|
-
if not path.is_file():
|
|
60
|
-
raise Malformed(f"{path} is missing")
|
|
61
|
-
try:
|
|
62
|
-
data = SPEC_VERIFY.loads_strict_json(path.read_text(encoding="utf-8"))
|
|
63
|
-
except (OSError, ValueError) as e:
|
|
64
|
-
raise Malformed(f"{path} is not valid JSON: {e}") from e
|
|
65
|
-
if not isinstance(data, dict):
|
|
66
|
-
raise Malformed(f"{path} must contain a JSON object")
|
|
67
|
-
return data
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def ensure_commit(work: pathlib.Path, sha: str, label: str) -> None:
|
|
71
|
-
proc = git(work, "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}")
|
|
72
|
-
if proc.returncode != 0:
|
|
73
|
-
raise Malformed(f"{label} is not a valid commit: {sha!r}")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def write_findings(devlyn_dir: pathlib.Path, findings: list[dict]) -> None:
|
|
77
|
-
devlyn_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
-
with (devlyn_dir / FINDINGS_NAME).open("w", encoding="utf-8") as handle:
|
|
79
|
-
for finding in findings:
|
|
80
|
-
handle.write(json.dumps(finding) + "\n")
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def write_summary(devlyn_dir: pathlib.Path, summary: dict) -> None:
|
|
84
|
-
devlyn_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
-
(devlyn_dir / SUMMARY_NAME).write_text(json.dumps(summary, sort_keys=True) + "\n", encoding="utf-8")
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def make_finding(
|
|
89
|
-
seq: int,
|
|
90
|
-
rule_id: str,
|
|
91
|
-
message: str,
|
|
92
|
-
file_ref: str,
|
|
93
|
-
*,
|
|
94
|
-
status: str,
|
|
95
|
-
fix_hint: str,
|
|
96
|
-
criterion_ref: str,
|
|
97
|
-
) -> dict:
|
|
98
|
-
return {
|
|
99
|
-
"id": f"FINISH-{seq:04d}",
|
|
100
|
-
"rule_id": rule_id,
|
|
101
|
-
"level": "error",
|
|
102
|
-
"severity": "CRITICAL",
|
|
103
|
-
"confidence": 1.0,
|
|
104
|
-
"message": message,
|
|
105
|
-
"file": file_ref,
|
|
106
|
-
"line": 1,
|
|
107
|
-
"phase": PHASE,
|
|
108
|
-
"criterion_ref": criterion_ref,
|
|
109
|
-
"fix_hint": fix_hint,
|
|
110
|
-
"blocking": True,
|
|
111
|
-
"status": status,
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def malformed_finding(error: Malformed) -> dict:
|
|
116
|
-
return make_finding(
|
|
117
|
-
1,
|
|
118
|
-
"scope.finish-gate-malformed",
|
|
119
|
-
str(error),
|
|
120
|
-
error.file_ref,
|
|
121
|
-
status="open",
|
|
122
|
-
criterion_ref="finish_gate/state",
|
|
123
|
-
fix_hint=(
|
|
124
|
-
"Restore a valid pipeline state and plan authorized_surface block "
|
|
125
|
-
"before final reporting can continue."
|
|
126
|
-
),
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def load_authorized_surface(devlyn_dir: pathlib.Path) -> list[str]:
|
|
131
|
-
plan_path = devlyn_dir / "plan.md"
|
|
132
|
-
if not plan_path.is_file():
|
|
133
|
-
raise Malformed(
|
|
134
|
-
"PHASE 6 finish gate requires .devlyn/plan.md with authorized_surface; the file is missing.",
|
|
135
|
-
".devlyn/plan.md",
|
|
136
|
-
)
|
|
137
|
-
try:
|
|
138
|
-
plan_text = plan_path.read_text(encoding="utf-8")
|
|
139
|
-
except OSError as e:
|
|
140
|
-
raise Malformed(f"Cannot read {plan_path}: {e}", ".devlyn/plan.md") from e
|
|
141
|
-
|
|
142
|
-
_found, block = SPEC_VERIFY.extract_authorized_surface_block(plan_text)
|
|
143
|
-
data = None
|
|
144
|
-
if block is None:
|
|
145
|
-
parse_error = (
|
|
146
|
-
"plan.md must include a `<!-- devlyn:authorized-surface -->` "
|
|
147
|
-
"section with a fenced ```json``` authorized_surface block."
|
|
148
|
-
)
|
|
149
|
-
else:
|
|
150
|
-
try:
|
|
151
|
-
data = SPEC_VERIFY.loads_strict_json(block)
|
|
152
|
-
parse_error = SPEC_VERIFY.validate_authorized_surface_shape(data)
|
|
153
|
-
except ValueError as e:
|
|
154
|
-
parse_error = f"authorized_surface json block is invalid JSON: {e}"
|
|
155
|
-
if parse_error:
|
|
156
|
-
raise Malformed(f"plan.md authorized_surface is malformed: {parse_error}", ".devlyn/plan.md")
|
|
157
|
-
return list(data["authorized_surface"])
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def cleanup_window(work: pathlib.Path, state: dict) -> set[str]:
|
|
161
|
-
if "cleanup" in (state.get("bypasses") or []):
|
|
162
|
-
return set()
|
|
163
|
-
phases = state.get("phases") or {}
|
|
164
|
-
cleanup = phases.get("cleanup") if isinstance(phases, dict) else None
|
|
165
|
-
if cleanup is None:
|
|
166
|
-
return set()
|
|
167
|
-
if not isinstance(cleanup, dict):
|
|
168
|
-
raise Malformed("phases.cleanup must be an object or null")
|
|
169
|
-
if cleanup.get("verdict") is None:
|
|
170
|
-
return set()
|
|
171
|
-
pre_sha = (cleanup.get("pre_sha") or "").strip()
|
|
172
|
-
post_sha = (cleanup.get("post_sha") or "").strip()
|
|
173
|
-
if not pre_sha or not post_sha:
|
|
174
|
-
raise Malformed("phases.cleanup with a verdict must include both pre_sha and post_sha")
|
|
175
|
-
ensure_commit(work, pre_sha, "phases.cleanup.pre_sha")
|
|
176
|
-
ensure_commit(work, post_sha, "phases.cleanup.post_sha")
|
|
177
|
-
proc = git(work, "diff", "--name-only", pre_sha, post_sha)
|
|
178
|
-
if proc.returncode != 0:
|
|
179
|
-
raise Malformed(f"cannot compute cleanup window: {proc.stderr.strip()}")
|
|
180
|
-
return {line.strip() for line in proc.stdout.splitlines() if line.strip()}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def changed_files(work: pathlib.Path, base_sha: str) -> set[str]:
|
|
184
|
-
proc = git(work, "diff", "--name-only", base_sha, "--")
|
|
185
|
-
if proc.returncode != 0:
|
|
186
|
-
detail = proc.stderr.strip() or proc.stdout.strip() or "git diff --name-only failed"
|
|
187
|
-
raise Malformed(f"cannot compute finish-gate changed files: {detail}")
|
|
188
|
-
return {line.strip() for line in proc.stdout.splitlines() if line.strip()}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def devlyn_relative_prefix(work: pathlib.Path, devlyn_dir: pathlib.Path) -> str:
|
|
192
|
-
try:
|
|
193
|
-
relative = devlyn_dir.resolve().relative_to(work.resolve()).as_posix().strip("/")
|
|
194
|
-
except ValueError as e:
|
|
195
|
-
raise Malformed(f"--devlyn-dir must be inside the work tree: {devlyn_dir}") from e
|
|
196
|
-
if not relative or relative == ".":
|
|
197
|
-
raise Malformed("--devlyn-dir must not be the work tree root")
|
|
198
|
-
return relative
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def is_under_prefix(path: str, prefix: str) -> bool:
|
|
202
|
-
return path == prefix or path.startswith(f"{prefix}/")
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def path_exists_at_base(work: pathlib.Path, base_sha: str, path: str) -> bool:
|
|
206
|
-
return git(work, "cat-file", "-e", f"{base_sha}:{path}").returncode == 0
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def remove_worktree_path(path: pathlib.Path) -> None:
|
|
210
|
-
if path.is_symlink() or path.is_file():
|
|
211
|
-
path.unlink()
|
|
212
|
-
elif path.is_dir():
|
|
213
|
-
shutil.rmtree(path)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def revert_offender(work: pathlib.Path, base_sha: str, path: str) -> tuple[bool, str | None, bool]:
|
|
217
|
-
existed_at_base = path_exists_at_base(work, base_sha, path)
|
|
218
|
-
if existed_at_base:
|
|
219
|
-
proc = git(work, "checkout", base_sha, "--", path)
|
|
220
|
-
if proc.returncode != 0:
|
|
221
|
-
return (False, proc.stderr.strip() or proc.stdout.strip(), True)
|
|
222
|
-
return (True, None, True)
|
|
223
|
-
|
|
224
|
-
proc = git(work, "rm", "-f", "--ignore-unmatch", "--", path)
|
|
225
|
-
target = work / path
|
|
226
|
-
try:
|
|
227
|
-
if target.exists() or target.is_symlink():
|
|
228
|
-
remove_worktree_path(target)
|
|
229
|
-
except OSError as e:
|
|
230
|
-
return (False, str(e), False)
|
|
231
|
-
if proc.returncode != 0:
|
|
232
|
-
cached = git(work, "rm", "-f", "--cached", "--ignore-unmatch", "--", path)
|
|
233
|
-
if cached.returncode != 0:
|
|
234
|
-
detail = proc.stderr.strip() or cached.stderr.strip() or proc.stdout.strip()
|
|
235
|
-
return (False, detail, False)
|
|
236
|
-
return (True, None, False)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def run_gate(work: pathlib.Path, devlyn_dir: pathlib.Path) -> int:
|
|
240
|
-
state_path = devlyn_dir / "pipeline.state.json"
|
|
241
|
-
findings_path = devlyn_dir / FINDINGS_NAME
|
|
242
|
-
try:
|
|
243
|
-
state = read_state(state_path)
|
|
244
|
-
findings_path.unlink(missing_ok=True)
|
|
245
|
-
if state.get("mode") == "verify-only":
|
|
246
|
-
write_summary(devlyn_dir, {"mode": "verify-only", "skipped": True, "exit": 0})
|
|
247
|
-
return 0
|
|
248
|
-
|
|
249
|
-
base_sha = ((state.get("base_ref") or {}).get("sha") or "").strip()
|
|
250
|
-
if not base_sha:
|
|
251
|
-
raise Malformed("pipeline.state.json must include non-empty base_ref.sha")
|
|
252
|
-
ensure_commit(work, base_sha, "base_ref.sha")
|
|
253
|
-
surface = load_authorized_surface(devlyn_dir)
|
|
254
|
-
cleanup_paths = cleanup_window(work, state)
|
|
255
|
-
changed = changed_files(work, base_sha)
|
|
256
|
-
devlyn_prefix = devlyn_relative_prefix(work, devlyn_dir)
|
|
257
|
-
checked = sorted(path for path in changed if not is_under_prefix(path, devlyn_prefix))
|
|
258
|
-
except Malformed as e:
|
|
259
|
-
findings_path.unlink(missing_ok=True)
|
|
260
|
-
write_findings(devlyn_dir, [malformed_finding(e)])
|
|
261
|
-
write_summary(devlyn_dir, {"exit": 1, "malformed": str(e)})
|
|
262
|
-
return 1
|
|
263
|
-
|
|
264
|
-
offenders = sorted(
|
|
265
|
-
path for path in checked
|
|
266
|
-
if (
|
|
267
|
-
not SPEC_VERIFY.path_matches_surface(path, surface)
|
|
268
|
-
and path not in cleanup_paths
|
|
269
|
-
)
|
|
270
|
-
)
|
|
271
|
-
if not offenders:
|
|
272
|
-
findings_path.unlink(missing_ok=True)
|
|
273
|
-
write_summary(devlyn_dir, {
|
|
274
|
-
"mode": state.get("mode"),
|
|
275
|
-
"checked": len(checked),
|
|
276
|
-
"offenders": 0,
|
|
277
|
-
"exit": 0,
|
|
278
|
-
})
|
|
279
|
-
return 0
|
|
280
|
-
|
|
281
|
-
findings: list[dict] = []
|
|
282
|
-
reverted = 0
|
|
283
|
-
revert_failed = 0
|
|
284
|
-
for seq, path in enumerate(offenders, start=1):
|
|
285
|
-
ok, detail, existed_at_base = revert_offender(work, base_sha, path)
|
|
286
|
-
status = "reverted" if ok else "revert-failed"
|
|
287
|
-
if ok:
|
|
288
|
-
reverted += 1
|
|
289
|
-
else:
|
|
290
|
-
revert_failed += 1
|
|
291
|
-
action = "restored to base_ref.sha" if existed_at_base else "removed because it did not exist at base_ref.sha"
|
|
292
|
-
message = f"Final diff touched an unaudited file outside authorized_surface and cleanup window: {path}"
|
|
293
|
-
if detail:
|
|
294
|
-
message = f"{message} ({detail})"
|
|
295
|
-
findings.append(make_finding(
|
|
296
|
-
seq,
|
|
297
|
-
"scope.finish-unaudited-file",
|
|
298
|
-
message,
|
|
299
|
-
path,
|
|
300
|
-
status=status,
|
|
301
|
-
criterion_ref="plan.md/authorized_surface",
|
|
302
|
-
fix_hint=(
|
|
303
|
-
"This file was outside authorized_surface plus the cleanup window "
|
|
304
|
-
f"and was {action}; do not ship unlicensed final-diff changes."
|
|
305
|
-
),
|
|
306
|
-
))
|
|
307
|
-
write_findings(devlyn_dir, findings)
|
|
308
|
-
# Exit 0 let a real run treat reverted orchestrator commits as a clean pass.
|
|
309
|
-
# Any offender is therefore unclean even when every automatic revert succeeds;
|
|
310
|
-
# the caller maps exit 1/2 to BLOCKED:finish-gate-unclean.
|
|
311
|
-
exit_code = 2
|
|
312
|
-
write_summary(devlyn_dir, {
|
|
313
|
-
"mode": state.get("mode"),
|
|
314
|
-
"checked": len(checked),
|
|
315
|
-
"offenders": len(offenders),
|
|
316
|
-
"reverted": reverted,
|
|
317
|
-
"revert_failed": revert_failed,
|
|
318
|
-
"exit": exit_code,
|
|
319
|
-
})
|
|
320
|
-
return exit_code
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def write_text(path: pathlib.Path, text: str) -> None:
|
|
324
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
325
|
-
path.write_text(text, encoding="utf-8")
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def write_state(devlyn: pathlib.Path, state: dict) -> None:
|
|
329
|
-
write_text(devlyn / "pipeline.state.json", json.dumps(state, indent=2) + "\n")
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
def read_findings(devlyn: pathlib.Path) -> list[dict]:
|
|
333
|
-
path = devlyn / FINDINGS_NAME
|
|
334
|
-
return [SPEC_VERIFY.loads_strict_json(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def read_summary(devlyn: pathlib.Path) -> dict:
|
|
338
|
-
return SPEC_VERIFY.loads_strict_json((devlyn / SUMMARY_NAME).read_text(encoding="utf-8"))
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def assert_summary(devlyn: pathlib.Path, expected: dict) -> None:
|
|
342
|
-
summary = read_summary(devlyn)
|
|
343
|
-
for key, value in expected.items():
|
|
344
|
-
assert summary.get(key) == value, summary
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def make_fixture(root: pathlib.Path, name: str, *, mode: str = "full") -> tuple[pathlib.Path, pathlib.Path, str]:
|
|
348
|
-
work = root / name
|
|
349
|
-
work.mkdir()
|
|
350
|
-
git_check(work, "init", "-q")
|
|
351
|
-
write_text(work / "src" / "app.txt", "base app\n")
|
|
352
|
-
write_text(work / "notes.txt", "base notes\n")
|
|
353
|
-
write_text(work / "cleanable.txt", "base cleanable\n")
|
|
354
|
-
git_check(work, "add", "-A")
|
|
355
|
-
git_check(work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "base")
|
|
356
|
-
base_sha = git_check(work, "rev-parse", "HEAD")
|
|
357
|
-
devlyn = work / ".devlyn"
|
|
358
|
-
write_text(
|
|
359
|
-
devlyn / "plan.md",
|
|
360
|
-
"# PLAN\n\n<!-- devlyn:authorized-surface -->\n## Files to touch\n\n"
|
|
361
|
-
"- `src/app.txt` (edit): implement the requested change.\n\n"
|
|
362
|
-
"```json\n"
|
|
363
|
-
'{"authorized_surface": ["src/app.txt"]}\n'
|
|
364
|
-
"```\n",
|
|
365
|
-
)
|
|
366
|
-
write_state(devlyn, {
|
|
367
|
-
"mode": mode,
|
|
368
|
-
"base_ref": {"sha": base_sha},
|
|
369
|
-
"phases": {"cleanup": None},
|
|
370
|
-
})
|
|
371
|
-
return (work, devlyn, base_sha)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def checked_run_gate(work: pathlib.Path, devlyn: pathlib.Path) -> int:
|
|
375
|
-
rc = run_gate(work, devlyn)
|
|
376
|
-
assert not any(path.name == "__pycache__" for path in work.rglob("__pycache__"))
|
|
377
|
-
return rc
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def self_test() -> int:
|
|
381
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
382
|
-
root = pathlib.Path(tmp)
|
|
383
|
-
|
|
384
|
-
work, devlyn, _base = make_fixture(root, "conforming")
|
|
385
|
-
write_text(work / "src" / "app.txt", "changed app\n")
|
|
386
|
-
assert checked_run_gate(work, devlyn) == 0
|
|
387
|
-
assert not (devlyn / FINDINGS_NAME).exists()
|
|
388
|
-
assert_summary(devlyn, {"mode": "full", "checked": 1, "offenders": 0, "exit": 0})
|
|
389
|
-
|
|
390
|
-
work, devlyn, _base = make_fixture(root, "tracked-mutation")
|
|
391
|
-
write_text(work / "notes.txt", "polluted\n")
|
|
392
|
-
assert checked_run_gate(work, devlyn) == 2
|
|
393
|
-
assert (work / "notes.txt").read_text(encoding="utf-8") == "base notes\n"
|
|
394
|
-
findings = read_findings(devlyn)
|
|
395
|
-
assert findings[0]["rule_id"] == "scope.finish-unaudited-file"
|
|
396
|
-
assert findings[0]["status"] == "reverted"
|
|
397
|
-
assert_summary(devlyn, {
|
|
398
|
-
"mode": "full", "checked": 1, "offenders": 1,
|
|
399
|
-
"reverted": 1, "revert_failed": 0, "exit": 2,
|
|
400
|
-
})
|
|
401
|
-
|
|
402
|
-
work, devlyn, _base = make_fixture(root, "added-file")
|
|
403
|
-
write_text(work / "runtime.txt", "late\n")
|
|
404
|
-
git_check(work, "add", "runtime.txt")
|
|
405
|
-
assert checked_run_gate(work, devlyn) == 2
|
|
406
|
-
assert not (work / "runtime.txt").exists()
|
|
407
|
-
assert "runtime.txt" not in git_check(work, "diff", "--name-only", _base).splitlines()
|
|
408
|
-
assert_summary(devlyn, {
|
|
409
|
-
"mode": "full", "checked": 1, "offenders": 1,
|
|
410
|
-
"reverted": 1, "revert_failed": 0, "exit": 2,
|
|
411
|
-
})
|
|
412
|
-
|
|
413
|
-
work, devlyn, _base = make_fixture(root, "devlyn-owned-tracked-mutation")
|
|
414
|
-
archived = devlyn / "runs" / "prior" / "pipeline.state.json"
|
|
415
|
-
write_text(archived, "tracked devlyn state\n")
|
|
416
|
-
git_check(work, "add", archived.relative_to(work).as_posix())
|
|
417
|
-
git_check(work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "track devlyn state")
|
|
418
|
-
write_text(archived, "mutated devlyn state\n")
|
|
419
|
-
write_text(work / "notes.txt", "polluted\n")
|
|
420
|
-
assert checked_run_gate(work, devlyn) == 2
|
|
421
|
-
assert archived.read_text(encoding="utf-8") == "mutated devlyn state\n"
|
|
422
|
-
assert (work / "notes.txt").read_text(encoding="utf-8") == "base notes\n"
|
|
423
|
-
findings = read_findings(devlyn)
|
|
424
|
-
assert [finding["file"] for finding in findings] == ["notes.txt"]
|
|
425
|
-
assert findings[0]["status"] == "reverted"
|
|
426
|
-
assert_summary(devlyn, {
|
|
427
|
-
"mode": "full", "checked": 1, "offenders": 1,
|
|
428
|
-
"reverted": 1, "revert_failed": 0, "exit": 2,
|
|
429
|
-
})
|
|
430
|
-
|
|
431
|
-
work, devlyn, base = make_fixture(root, "cleanup-window")
|
|
432
|
-
write_text(work / "cleanable.txt", "cleanup changed\n")
|
|
433
|
-
git_check(work, "add", "cleanable.txt")
|
|
434
|
-
git_check(work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "cleanup")
|
|
435
|
-
post_sha = git_check(work, "rev-parse", "HEAD")
|
|
436
|
-
write_state(devlyn, {
|
|
437
|
-
"mode": "full",
|
|
438
|
-
"base_ref": {"sha": base},
|
|
439
|
-
"phases": {"cleanup": {"verdict": "PASS", "pre_sha": base, "post_sha": post_sha}},
|
|
440
|
-
})
|
|
441
|
-
assert checked_run_gate(work, devlyn) == 0
|
|
442
|
-
assert not (devlyn / FINDINGS_NAME).exists()
|
|
443
|
-
assert_summary(devlyn, {"mode": "full", "checked": 1, "offenders": 0, "exit": 0})
|
|
444
|
-
|
|
445
|
-
work, devlyn, base = make_fixture(root, "cleanup-malformed")
|
|
446
|
-
write_state(devlyn, {
|
|
447
|
-
"mode": "full",
|
|
448
|
-
"base_ref": {"sha": base},
|
|
449
|
-
"phases": {"cleanup": {"verdict": "PASS", "pre_sha": base}},
|
|
450
|
-
})
|
|
451
|
-
assert checked_run_gate(work, devlyn) == 1
|
|
452
|
-
assert read_findings(devlyn)[0]["rule_id"] == "scope.finish-gate-malformed"
|
|
453
|
-
summary = read_summary(devlyn)
|
|
454
|
-
assert summary["exit"] == 1, summary
|
|
455
|
-
assert "phases.cleanup with a verdict" in summary["malformed"], summary
|
|
456
|
-
|
|
457
|
-
work, devlyn, _base = make_fixture(root, "verify-only", mode="verify-only")
|
|
458
|
-
(devlyn / "plan.md").unlink()
|
|
459
|
-
write_findings(devlyn, [make_finding(
|
|
460
|
-
1,
|
|
461
|
-
"scope.finish-unaudited-file",
|
|
462
|
-
"stale",
|
|
463
|
-
"stale.txt",
|
|
464
|
-
status="reverted",
|
|
465
|
-
criterion_ref="plan.md/authorized_surface",
|
|
466
|
-
fix_hint="stale",
|
|
467
|
-
)])
|
|
468
|
-
write_state(devlyn, {"mode": "verify-only", "phases": {}})
|
|
469
|
-
assert checked_run_gate(work, devlyn) == 0
|
|
470
|
-
assert not (devlyn / FINDINGS_NAME).exists()
|
|
471
|
-
assert read_summary(devlyn) == {"exit": 0, "mode": "verify-only", "skipped": True}
|
|
472
|
-
|
|
473
|
-
work, devlyn, _base = make_fixture(root, "deleted-file")
|
|
474
|
-
(work / "notes.txt").unlink()
|
|
475
|
-
assert checked_run_gate(work, devlyn) == 2
|
|
476
|
-
assert (work / "notes.txt").read_text(encoding="utf-8") == "base notes\n"
|
|
477
|
-
assert read_findings(devlyn)[0]["status"] == "reverted"
|
|
478
|
-
assert_summary(devlyn, {
|
|
479
|
-
"mode": "full", "checked": 1, "offenders": 1,
|
|
480
|
-
"reverted": 1, "revert_failed": 0, "exit": 2,
|
|
481
|
-
})
|
|
482
|
-
|
|
483
|
-
work, devlyn, _base = make_fixture(root, "git-diff-failure")
|
|
484
|
-
original_git = globals()["git"]
|
|
485
|
-
|
|
486
|
-
def failing_git(work_arg: pathlib.Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
487
|
-
if args[:2] == ("diff", "--name-only"):
|
|
488
|
-
return subprocess.CompletedProcess(["git", *args], 128, "", "simulated diff failure")
|
|
489
|
-
return original_git(work_arg, *args)
|
|
490
|
-
|
|
491
|
-
globals()["git"] = failing_git
|
|
492
|
-
try:
|
|
493
|
-
assert checked_run_gate(work, devlyn) == 1
|
|
494
|
-
finding = read_findings(devlyn)[0]
|
|
495
|
-
assert finding["rule_id"] == "scope.finish-gate-malformed"
|
|
496
|
-
assert "cannot compute finish-gate changed files" in finding["message"]
|
|
497
|
-
summary = read_summary(devlyn)
|
|
498
|
-
assert summary["exit"] == 1, summary
|
|
499
|
-
assert "cannot compute finish-gate changed files" in summary["malformed"], summary
|
|
500
|
-
finally:
|
|
501
|
-
globals()["git"] = original_git
|
|
502
|
-
|
|
503
|
-
return 0
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
def main() -> int:
|
|
507
|
-
ap = argparse.ArgumentParser(description=__doc__)
|
|
508
|
-
ap.add_argument("--devlyn-dir", default=".devlyn")
|
|
509
|
-
ap.add_argument("--self-test", action="store_true")
|
|
510
|
-
args = ap.parse_args()
|
|
511
|
-
if args.self_test:
|
|
512
|
-
return self_test()
|
|
513
|
-
devlyn_dir = pathlib.Path(args.devlyn_dir)
|
|
514
|
-
if not devlyn_dir.is_absolute():
|
|
515
|
-
devlyn_dir = (pathlib.Path.cwd() / devlyn_dir).resolve()
|
|
516
|
-
return run_gate(devlyn_dir.parent, devlyn_dir)
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
if __name__ == "__main__":
|
|
520
|
-
runpy.run_path(str(pathlib.Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
521
|
-
raise SystemExit(main())
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Deny pair-judge shell outside the probe anchor, with a model-readable reason.
|
|
3
|
-
|
|
4
|
-
Registered as a native grok `PreToolUse` hook. It fires at step 1 of the
|
|
5
|
-
authorization pipeline, before the permission rules and before the mode policy
|
|
6
|
-
whose auto-deny silently terminates the review at exit 0 (iter-0081).
|
|
7
|
-
|
|
8
|
-
The anchor is supplied via DEVLYN_PROBE_ANCHOR, derived by the orchestrator from
|
|
9
|
-
the same source as the --allow rule.
|
|
10
|
-
|
|
11
|
-
Measured (iter-0081 M-C6 v2, external isolation, admissible cell): Line A 6/6 —
|
|
12
|
-
the denial arrives as a model-visible tool_result, the judge re-routes to
|
|
13
|
-
read_file, the anchor still executes inside the grok process, EndTurn with zero
|
|
14
|
-
PermissionCancelled. Line A-chain 1/1 — `<anchor> && <write>` is denied
|
|
15
|
-
model-visibly, neither segment executes, and the judge then runs the bare anchor.
|
|
16
|
-
"""
|
|
17
|
-
import json
|
|
18
|
-
import os
|
|
19
|
-
import sys
|
|
20
|
-
|
|
21
|
-
REASON = (
|
|
22
|
-
"DEVLYN-0081-HOOK-DENY: this shell command is outside the permitted probe "
|
|
23
|
-
"anchor. Use the read_file tool for file reads. The only permitted shell "
|
|
24
|
-
"command is the anchor named in your prompt, unchained."
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
SHELL_TOOLS = {"run_terminal_command", "run_terminal_cmd", "Bash"}
|
|
28
|
-
|
|
29
|
-
# A conservative, quote-OBLIVIOUS lexical veto — deliberately not a shell parser.
|
|
30
|
-
# It rejects these characters even inside quotes, over-denying rather than ever
|
|
31
|
-
# under-denying a chain. Adding quote-, escape-, or operator-aware exceptions is
|
|
32
|
-
# how this becomes a parser; that is a registered falsifier, not a polish.
|
|
33
|
-
FORBIDDEN = set(";&|`$()<>") | {"\n", "\r"}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def reject_json_constant(token):
|
|
37
|
-
raise ValueError(f"invalid JSON numeric constant: {token}")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def reject_duplicate_keys(pairs):
|
|
41
|
-
result = {}
|
|
42
|
-
for key, value in pairs:
|
|
43
|
-
if key in result:
|
|
44
|
-
raise ValueError(f"duplicate JSON key: {key}")
|
|
45
|
-
result[key] = value
|
|
46
|
-
return result
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def main():
|
|
50
|
-
anchor = os.environ.get("DEVLYN_PROBE_ANCHOR", "")
|
|
51
|
-
payload = json.load(
|
|
52
|
-
sys.stdin,
|
|
53
|
-
parse_constant=reject_json_constant,
|
|
54
|
-
object_pairs_hook=reject_duplicate_keys,
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
if payload.get("toolName") not in SHELL_TOOLS or not anchor:
|
|
58
|
-
print(json.dumps({"decision": "allow"}))
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
-
# The anchor, or the anchor plus argv, and nothing that could start a second
|
|
62
|
-
# command. A bare prefix rule would allow `<anchor> && <other>` — the vendor's
|
|
63
|
-
# own documented footgun. Exact match would instead deny `<anchor> --n 5`,
|
|
64
|
-
# mechanically destroying the "bounded input variations" that verify.md
|
|
65
|
-
# requires of every engine. Both were rejected by both seats (iter-0081 v2).
|
|
66
|
-
command = (payload.get("toolInput") or {}).get("command", "").strip()
|
|
67
|
-
if command == anchor or (
|
|
68
|
-
command.startswith(anchor + " ")
|
|
69
|
-
and not any(ch in FORBIDDEN for ch in command)
|
|
70
|
-
):
|
|
71
|
-
print(json.dumps({"decision": "allow"}))
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
print(json.dumps({"decision": "deny", "reason": REASON}))
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if __name__ == "__main__":
|
|
78
|
-
main()
|