devlyn-cli 2.8.1 → 2.9.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 +5 -1
- package/CLAUDE.md +5 -1
- package/config/skills/_shared/adapters/claude.md +6 -3
- package/config/skills/_shared/archive_run.py +25 -1
- package/config/skills/_shared/codex-config.md +1 -0
- package/config/skills/_shared/finish-gate.py +517 -0
- package/config/skills/_shared/run-bounded.py +44 -0
- package/config/skills/_shared/spec-verify-check.py +716 -93
- package/config/skills/_shared/state-phase-write.py +113 -15
- package/config/skills/_shared/verify-merge-findings.py +197 -1
- package/config/skills/devlyn:resolve/SKILL.md +37 -20
- package/config/skills/devlyn:resolve/references/free-form-mode.md +7 -7
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/probe-derive.md +14 -3
- package/config/skills/devlyn:resolve/references/phases/verify.md +19 -4
- package/config/skills/devlyn:resolve/references/state-schema.md +16 -9
- package/package.json +1 -1
- package/scripts/lint-skills.sh +1 -1
package/AGENTS.md
CHANGED
|
@@ -38,7 +38,7 @@ Each skill's `SKILL.md` is the source of truth for flags and workflow. Do not du
|
|
|
38
38
|
|
|
39
39
|
Engine roles: orchestrator = whichever CLI the user opened (this contract is symmetric with CLAUDE.md; measured status 2026-07-05: Claude Code + omp run the full phase-gated pipeline; in the iter-0061 minimal-repo trivial-add shape on this machine, Codex CLI also ran it when this AGENTS.md was present in the project (ordinary invocation, 4/4); without project AGENTS.md, the same shape silently skipped the pipeline (iter-0040 F6, 4/4). If you are Codex orchestrating `/devlyn:resolve`, the phase machinery is mandatory regardless of task size; if you will not run it, say so explicitly and stop — do not silently degrade to ad-hoc execution); executor (PLAN/IMPLEMENT/CLEANUP) defaults to `claude`, overridable per run with `--engine <name>` or durably via machine-local `.devlyn/engines.json` `{"executor": "<name>", "pair_judge_priority": ["<name>", ...]}`; pair judge = first available OTHER engine. Pins fail closed (`BLOCKED:<engine>-unavailable` / `BLOCKED:invalid-engine-config`); new engines plug in by shipping `_shared/adapters/<name>.md`. `/devlyn:engines` (no args) shows the role table + detected engines; `executor <name>` / `pair <name>,...` / `clear` manage the pins. The executor pin binds the orchestrator in plain conversation too, not only inside a skill run: when you would implement directly and executor is pinned to a non-default engine, route that work through the pin — via `/devlyn:resolve` (reads it at PHASE 0) or by delegating to that engine — instead of editing as `claude`; no pin / no file → unchanged (`claude`). The pair-judge pin stays pipeline-scoped.
|
|
40
40
|
|
|
41
|
-
Conversational handoff + loop engineering (default entry): the orchestrating model — not the user — invokes the skills. `/devlyn:queue` fronts the intent queue (`docs/specs/queue.md`): no args = status, `add <intent>`, `drain` = serial unattended drain per the contract below. Large agreed work is written to `docs/specs/<id>/spec.md` (always a spec file;
|
|
41
|
+
Conversational handoff + loop engineering (default entry): the orchestrating model — not the user — invokes the skills. `/devlyn:queue` fronts the intent queue (`docs/specs/queue.md`): no args = status, `add <intent>`, `drain` = serial unattended drain per the contract below. Large agreed work is written to `docs/specs/<id>/spec.md` (always a spec file — the user's reviewed contract; free-form large assumes-and-logs instead of halting, zero-scope-signal goals still halt to ideate), summarized once for user review, then run hands-free via `--spec`. Outer loop per task: findings-backed verdicts (NEEDS_WORK, verify/build-gate exhaustion) get spec amendment + re-run, max 3 iterations; infrastructure / invalid-input / engine-availability / implement-empty BLOCKED verdicts surface immediately. Unattended queue drain (`docs/specs/queue.md`, strictly serial): assumptions may only take scope-narrowing, reversible, non-user-visible defaults — material ambiguity marks the item `[F] needs-review` and the drain continues.
|
|
42
42
|
|
|
43
43
|
## Subtractive-First Editing
|
|
44
44
|
|
|
@@ -106,6 +106,10 @@ Exclude vague claims. They produce vague fixes.
|
|
|
106
106
|
- For installer or skill edits inside the devlyn-cli source repo, run `bash scripts/lint-skills.sh`.
|
|
107
107
|
- Treat the project's own `docs/VISION.md`, `docs/ROADMAP.md`, `docs/roadmap/**`, and any local `AGENTS.md` / `CLAUDE.md` overrides as authoritative when present.
|
|
108
108
|
|
|
109
|
+
### Evolution loop — orchestrator-neutral continuation
|
|
110
|
+
|
|
111
|
+
Any orchestrating engine (Claude, Codex, or future) continuing harness / loop-engineering work cold-starts from `autoresearch/HANDOFF.md` (read order inside), then `autoresearch/NORTH-STAR.md` (goal + floor/ceiling contracts + ops-test gates) and `autoresearch/PRINCIPLES.md`. Instruments and gates are scripts + files, never model memory. The pair partner is the strongest available OTHER engine — the protocol is direction-symmetric (iter-0060). On any model/version change, re-certify engine seats with the seat-fitness instrument before re-pinning `.devlyn/engines.json` (NORTH-STAR ceiling contract; iter-0064).
|
|
112
|
+
|
|
109
113
|
## Communication
|
|
110
114
|
|
|
111
115
|
- Lead with objective evidence before opinion.
|
package/CLAUDE.md
CHANGED
|
@@ -53,7 +53,7 @@ Each skill's `SKILL.md` is the source of truth for its flags and workflow — do
|
|
|
53
53
|
|
|
54
54
|
The user does not invoke skills manually; the orchestrating model does. Small tasks: invoke `/devlyn:resolve "<goal>"` directly. Large tasks agreed in conversation:
|
|
55
55
|
|
|
56
|
-
1. Write the agreed contract to `docs/specs/<id>/spec.md` (+ `spec.expected.json` when mechanical verifications exist). Always a spec file for large work —
|
|
56
|
+
1. Write the agreed contract to `docs/specs/<id>/spec.md` (+ `spec.expected.json` when mechanical verifications exist). Always a spec file for large work — a written spec is the user's reviewed contract; free-form large now assumes-and-logs instead of halting (zero-scope-signal goals still halt to ideate).
|
|
57
57
|
2. Present a one-screen plan-contract summary — the user's single review checkpoint, BEFORE the pipeline starts.
|
|
58
58
|
3. On go-ahead, invoke `/devlyn:resolve --spec <path>` hands-free to completion.
|
|
59
59
|
4. **Per-task outer loop**: read the terminal verdict. PASS → done. Verdicts backed by spec/verification findings (NEEDS_WORK, verify/build-gate exhaustion) → adjudicate the findings, amend the spec (recorded in the spec file; spec stays read-only inside a run), re-invoke — at most 3 outer iterations, then surface with the findings trail. Infrastructure, invalid-input, engine-availability, and implement-empty BLOCKED verdicts are not spec-amendable: surface immediately. Every iteration re-enters through durable artifacts (spec, findings, run archive), never conversation memory.
|
|
@@ -127,6 +127,10 @@ This rule exists because LLMs (including you) are trained to be helpful, compreh
|
|
|
127
127
|
**Stopping rule.** A task is done when the user's stated goal is closed AND no off-path work was added. If you find yourself hesitating because "I should also do Z" — Z is drift. Note it for follow-up, do not execute.
|
|
128
128
|
<!-- runtime-principles:section=goal-locked:end -->
|
|
129
129
|
|
|
130
|
+
## Evolution loop — orchestrator-neutral continuation
|
|
131
|
+
|
|
132
|
+
Any orchestrating engine (Claude, Codex, or future) continuing harness / loop-engineering work cold-starts from `autoresearch/HANDOFF.md` (read order inside), then `autoresearch/NORTH-STAR.md` (goal + floor/ceiling contracts + ops-test gates) and `autoresearch/PRINCIPLES.md`. Instruments and gates are scripts + files, never model memory. The pair partner is the strongest available OTHER engine — the protocol is direction-symmetric (iter-0060). On any model/version change, re-certify engine seats with the seat-fitness instrument before re-pinning `.devlyn/engines.json` (NORTH-STAR ceiling contract; iter-0064).
|
|
133
|
+
|
|
130
134
|
## Error Handling Philosophy
|
|
131
135
|
|
|
132
136
|
**No silent fallbacks.** Handle errors explicitly and show the user what happened.
|
|
@@ -22,7 +22,7 @@ two targeted probes; the invoking phase sets `--effort`, pair-JUDGE uses
|
|
|
22
22
|
`medium`):
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
claude -p "<judge prompt>" \
|
|
25
|
+
python3 "$DEVLYN_SHARED_DIR/run-bounded.py" 600 -- claude -p "<judge prompt>" \
|
|
26
26
|
--permission-mode dontAsk \
|
|
27
27
|
--allowedTools "Read,Grep,Glob,Bash(<repo test command> *)" \
|
|
28
28
|
--setting-sources project --strict-mcp-config --mcp-config '{"mcpServers":{}}' \
|
|
@@ -46,8 +46,11 @@ claude -p "<judge prompt>" \
|
|
|
46
46
|
`.devlyn/verify.pair.findings.jsonl`. Raw stdout stays diagnostic at
|
|
47
47
|
`.devlyn/claude-judge.stdout`; `verify-merge-findings.py` blocks the run
|
|
48
48
|
if stdout contains findings the canonical file lacks.
|
|
49
|
-
-
|
|
50
|
-
|
|
49
|
+
- Exit 124 is a wall-budget abort (kills the process group) → the orchestrator
|
|
50
|
+
writes `.devlyn/verify.pair.timeout.json`; budget abort ≠ availability failure
|
|
51
|
+
and the fail-closed availability rules above are unchanged.
|
|
52
|
+
- Do not pipe stdout (`| tail`, `| grep`); capture to file. Non-zero exit other
|
|
53
|
+
than 124 or empty stdout → spawn failure, fail closed per the probe rule above.
|
|
51
54
|
|
|
52
55
|
## Identity
|
|
53
56
|
|
|
@@ -29,6 +29,8 @@ PER_RUN_PATTERNS = (
|
|
|
29
29
|
"fix-batch.round-*.json",
|
|
30
30
|
"criteria.generated.md",
|
|
31
31
|
"risk-probes.jsonl",
|
|
32
|
+
# Probe scripts referenced by risk-probes.jsonl are archived by
|
|
33
|
+
# move_probe_scripts() to preserve probes/<file> layout.
|
|
32
34
|
# iter-0019.8: spec-verify carrier artifacts get archived alongside
|
|
33
35
|
# other per-run state. Killed mid-run cleanup is enforced separately
|
|
34
36
|
# by spec-verify-check.py main() — when source markdown has no json
|
|
@@ -39,6 +41,7 @@ PER_RUN_PATTERNS = (
|
|
|
39
41
|
"spec-verify.results.json",
|
|
40
42
|
"spec-verify-findings.jsonl",
|
|
41
43
|
"verify-merge.summary.json",
|
|
44
|
+
"finish-gate.summary.json",
|
|
42
45
|
# iter-0033a/2026-04-30 archive-fix iter: NEW /devlyn:resolve emits
|
|
43
46
|
# plan.md (PLAN output) + final-report.md (PHASE 6 render) +
|
|
44
47
|
# cumulative.patch (cumulative diff). Smoke 2's archive listing
|
|
@@ -96,6 +99,24 @@ def move_artifacts(devlyn: pathlib.Path, dest: pathlib.Path) -> int:
|
|
|
96
99
|
if src.is_file():
|
|
97
100
|
shutil.move(str(src), str(dest / src.name))
|
|
98
101
|
moved += 1
|
|
102
|
+
moved += move_probe_scripts(devlyn, dest)
|
|
103
|
+
return moved
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def move_probe_scripts(devlyn: pathlib.Path, dest: pathlib.Path) -> int:
|
|
107
|
+
probes = devlyn / "probes"
|
|
108
|
+
if not probes.is_dir():
|
|
109
|
+
return 0
|
|
110
|
+
moved = 0
|
|
111
|
+
for src in sorted(probes.rglob("*")):
|
|
112
|
+
if not src.is_file():
|
|
113
|
+
continue
|
|
114
|
+
target = dest / src.relative_to(devlyn)
|
|
115
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
shutil.move(str(src), str(target))
|
|
117
|
+
moved += 1
|
|
118
|
+
if probes.exists():
|
|
119
|
+
shutil.rmtree(probes, ignore_errors=False)
|
|
99
120
|
return moved
|
|
100
121
|
|
|
101
122
|
|
|
@@ -146,13 +167,16 @@ def self_test() -> int:
|
|
|
146
167
|
"claude-judge.stderr",
|
|
147
168
|
):
|
|
148
169
|
(devlyn / name).write_text("{}\n", encoding="utf-8")
|
|
170
|
+
(devlyn / "probes").mkdir()
|
|
171
|
+
(devlyn / "probes" / "P1.py").write_text("print('probe')\n", encoding="utf-8")
|
|
149
172
|
run_id = read_run_id(devlyn)
|
|
150
173
|
assert run_id == "run-1", run_id
|
|
151
174
|
moved = move_artifacts(devlyn, devlyn / "runs" / run_id)
|
|
152
|
-
assert moved >=
|
|
175
|
+
assert moved >= 9, moved
|
|
153
176
|
for name in (
|
|
154
177
|
"pipeline.state.json",
|
|
155
178
|
"risk-probes.jsonl",
|
|
179
|
+
"probes/P1.py",
|
|
156
180
|
"verify.pair.findings.jsonl",
|
|
157
181
|
"verify-merge.summary.json",
|
|
158
182
|
"codex-judge.stdout",
|
|
@@ -48,6 +48,7 @@ Notes:
|
|
|
48
48
|
- `-c model_reasoning_effort=xhigh` — config override for reasoning depth. Required for deep critique; skills may choose `high` or `medium` when thoroughness doesn't warrant xhigh.
|
|
49
49
|
- **Omit `-m <model>`** — Codex CLI uses its configured flagship (currently `gpt-5.5`, automatically whatever ships next). This is the zero-touch mechanism. Only name `-m` when a role explicitly needs a different model (e.g., `gpt-5.3-codex` for SWE-bench-heavy coding tasks, `gpt-5.3-codex-spark` for speed).
|
|
50
50
|
- `CODEX_MONITORED_ISOLATED=1` — required for bounded read-only critique/probe/judge calls. The wrapper adds `--ignore-user-config --ignore-rules --ephemeral --disable codex_hooks --disable hooks` so user config, AGENTS.md, hooks, and project rules cannot add hidden context, tool calls, or transcript side effects. Do not set it for workspace-write implementation phases.
|
|
51
|
+
- Wrapper calls are **foreground-blocking**. Never launch them via a backgrounded shell (`run_in_background`, `&`, `nohup`) and never end the orchestrator message while one runs: a headless print-mode session kills backgrounded children at wind-down (observed 2026-07-07: an FS1 A-arm IMPLEMENT codex call was killed at turn end → 0-byte delivery). The heartbeat stream is the observability channel; block on the call.
|
|
51
52
|
- Raw `codex exec ...` invocations are **forbidden** in skill prompts. The benchmark variant arm runs a PATH shim (`scripts/codex-shim/codex`) that transparently re-routes any raw `codex exec` to the wrapper as a safety net, but skills should always emit the wrapper form directly so the orchestrator's first-attempt has the right shape. Two prior iterations (iter-0006 universal foreground ban, iter-0008 prompt-level kill-shape contract) failed because the orchestrator picked starvation-prone shapes (`codex exec ... 2>&1 | tail -200`) from its own pattern prior — the wrapper plus the shim is the runtime binding layer those iters lacked. See `autoresearch/iterations/0009-wrapper-and-hook.md`.
|
|
52
53
|
|
|
53
54
|
## Availability check
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic PHASE 6 final-diff gate for /devlyn:resolve."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import pathlib
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
sys.dont_write_bytecode = True
|
|
15
|
+
|
|
16
|
+
FINDINGS_NAME = "finish-gate.findings.jsonl"
|
|
17
|
+
SUMMARY_NAME = "finish-gate.summary.json"
|
|
18
|
+
PHASE = "finish_gate"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Malformed(Exception):
|
|
22
|
+
def __init__(self, message: str, file_ref: str = ".devlyn/pipeline.state.json"):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.file_ref = file_ref
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_spec_verify():
|
|
28
|
+
helper = pathlib.Path(__file__).with_name("spec-verify-check.py")
|
|
29
|
+
spec = importlib.util.spec_from_file_location("devlyn_spec_verify_check", helper)
|
|
30
|
+
if spec is None or spec.loader is None:
|
|
31
|
+
raise RuntimeError(f"cannot load {helper}")
|
|
32
|
+
module = importlib.util.module_from_spec(spec)
|
|
33
|
+
spec.loader.exec_module(module)
|
|
34
|
+
return module
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SPEC_VERIFY = load_spec_verify()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def git(work: pathlib.Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
41
|
+
return subprocess.run(
|
|
42
|
+
["git", *args],
|
|
43
|
+
cwd=str(work),
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def git_check(work: pathlib.Path, *args: str) -> str:
|
|
50
|
+
proc = git(work, *args)
|
|
51
|
+
if proc.returncode != 0:
|
|
52
|
+
raise AssertionError(proc.stderr or proc.stdout or f"git {' '.join(args)} failed")
|
|
53
|
+
return proc.stdout.strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def read_state(path: pathlib.Path) -> dict:
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
raise Malformed(f"{path} is missing")
|
|
59
|
+
try:
|
|
60
|
+
data = SPEC_VERIFY.loads_strict_json(path.read_text(encoding="utf-8"))
|
|
61
|
+
except (OSError, ValueError) as e:
|
|
62
|
+
raise Malformed(f"{path} is not valid JSON: {e}") from e
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
raise Malformed(f"{path} must contain a JSON object")
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ensure_commit(work: pathlib.Path, sha: str, label: str) -> None:
|
|
69
|
+
proc = git(work, "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}")
|
|
70
|
+
if proc.returncode != 0:
|
|
71
|
+
raise Malformed(f"{label} is not a valid commit: {sha!r}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def write_findings(devlyn_dir: pathlib.Path, findings: list[dict]) -> None:
|
|
75
|
+
devlyn_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
with (devlyn_dir / FINDINGS_NAME).open("w", encoding="utf-8") as handle:
|
|
77
|
+
for finding in findings:
|
|
78
|
+
handle.write(json.dumps(finding) + "\n")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write_summary(devlyn_dir: pathlib.Path, summary: dict) -> None:
|
|
82
|
+
devlyn_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
(devlyn_dir / SUMMARY_NAME).write_text(json.dumps(summary, sort_keys=True) + "\n", encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def make_finding(
|
|
87
|
+
seq: int,
|
|
88
|
+
rule_id: str,
|
|
89
|
+
message: str,
|
|
90
|
+
file_ref: str,
|
|
91
|
+
*,
|
|
92
|
+
status: str,
|
|
93
|
+
fix_hint: str,
|
|
94
|
+
criterion_ref: str,
|
|
95
|
+
) -> dict:
|
|
96
|
+
return {
|
|
97
|
+
"id": f"FINISH-{seq:04d}",
|
|
98
|
+
"rule_id": rule_id,
|
|
99
|
+
"level": "error",
|
|
100
|
+
"severity": "CRITICAL",
|
|
101
|
+
"confidence": 1.0,
|
|
102
|
+
"message": message,
|
|
103
|
+
"file": file_ref,
|
|
104
|
+
"line": 1,
|
|
105
|
+
"phase": PHASE,
|
|
106
|
+
"criterion_ref": criterion_ref,
|
|
107
|
+
"fix_hint": fix_hint,
|
|
108
|
+
"blocking": True,
|
|
109
|
+
"status": status,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def malformed_finding(error: Malformed) -> dict:
|
|
114
|
+
return make_finding(
|
|
115
|
+
1,
|
|
116
|
+
"scope.finish-gate-malformed",
|
|
117
|
+
str(error),
|
|
118
|
+
error.file_ref,
|
|
119
|
+
status="open",
|
|
120
|
+
criterion_ref="finish_gate/state",
|
|
121
|
+
fix_hint=(
|
|
122
|
+
"Restore a valid pipeline state and plan authorized_surface block "
|
|
123
|
+
"before final reporting can continue."
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def load_authorized_surface(devlyn_dir: pathlib.Path) -> list[str]:
|
|
129
|
+
plan_path = devlyn_dir / "plan.md"
|
|
130
|
+
if not plan_path.is_file():
|
|
131
|
+
raise Malformed(
|
|
132
|
+
"PHASE 6 finish gate requires .devlyn/plan.md with authorized_surface; the file is missing.",
|
|
133
|
+
".devlyn/plan.md",
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
plan_text = plan_path.read_text(encoding="utf-8")
|
|
137
|
+
except OSError as e:
|
|
138
|
+
raise Malformed(f"Cannot read {plan_path}: {e}", ".devlyn/plan.md") from e
|
|
139
|
+
|
|
140
|
+
_found, block = SPEC_VERIFY.extract_authorized_surface_block(plan_text)
|
|
141
|
+
data = None
|
|
142
|
+
if block is None:
|
|
143
|
+
parse_error = (
|
|
144
|
+
"plan.md must include a `<!-- devlyn:authorized-surface -->` "
|
|
145
|
+
"section with a fenced ```json``` authorized_surface block."
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
try:
|
|
149
|
+
data = SPEC_VERIFY.loads_strict_json(block)
|
|
150
|
+
parse_error = SPEC_VERIFY.validate_authorized_surface_shape(data)
|
|
151
|
+
except ValueError as e:
|
|
152
|
+
parse_error = f"authorized_surface json block is invalid JSON: {e}"
|
|
153
|
+
if parse_error:
|
|
154
|
+
raise Malformed(f"plan.md authorized_surface is malformed: {parse_error}", ".devlyn/plan.md")
|
|
155
|
+
return list(data["authorized_surface"])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cleanup_window(work: pathlib.Path, state: dict) -> set[str]:
|
|
159
|
+
if "cleanup" in (state.get("bypasses") or []):
|
|
160
|
+
return set()
|
|
161
|
+
phases = state.get("phases") or {}
|
|
162
|
+
cleanup = phases.get("cleanup") if isinstance(phases, dict) else None
|
|
163
|
+
if cleanup is None:
|
|
164
|
+
return set()
|
|
165
|
+
if not isinstance(cleanup, dict):
|
|
166
|
+
raise Malformed("phases.cleanup must be an object or null")
|
|
167
|
+
if cleanup.get("verdict") is None:
|
|
168
|
+
return set()
|
|
169
|
+
pre_sha = (cleanup.get("pre_sha") or "").strip()
|
|
170
|
+
post_sha = (cleanup.get("post_sha") or "").strip()
|
|
171
|
+
if not pre_sha or not post_sha:
|
|
172
|
+
raise Malformed("phases.cleanup with a verdict must include both pre_sha and post_sha")
|
|
173
|
+
ensure_commit(work, pre_sha, "phases.cleanup.pre_sha")
|
|
174
|
+
ensure_commit(work, post_sha, "phases.cleanup.post_sha")
|
|
175
|
+
proc = git(work, "diff", "--name-only", pre_sha, post_sha)
|
|
176
|
+
if proc.returncode != 0:
|
|
177
|
+
raise Malformed(f"cannot compute cleanup window: {proc.stderr.strip()}")
|
|
178
|
+
return {line.strip() for line in proc.stdout.splitlines() if line.strip()}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def changed_files(work: pathlib.Path, base_sha: str) -> set[str]:
|
|
182
|
+
proc = git(work, "diff", "--name-only", base_sha, "--")
|
|
183
|
+
if proc.returncode != 0:
|
|
184
|
+
detail = proc.stderr.strip() or proc.stdout.strip() or "git diff --name-only failed"
|
|
185
|
+
raise Malformed(f"cannot compute finish-gate changed files: {detail}")
|
|
186
|
+
return {line.strip() for line in proc.stdout.splitlines() if line.strip()}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def devlyn_relative_prefix(work: pathlib.Path, devlyn_dir: pathlib.Path) -> str:
|
|
190
|
+
try:
|
|
191
|
+
relative = devlyn_dir.resolve().relative_to(work.resolve()).as_posix().strip("/")
|
|
192
|
+
except ValueError as e:
|
|
193
|
+
raise Malformed(f"--devlyn-dir must be inside the work tree: {devlyn_dir}") from e
|
|
194
|
+
if not relative or relative == ".":
|
|
195
|
+
raise Malformed("--devlyn-dir must not be the work tree root")
|
|
196
|
+
return relative
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def is_under_prefix(path: str, prefix: str) -> bool:
|
|
200
|
+
return path == prefix or path.startswith(f"{prefix}/")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def path_exists_at_base(work: pathlib.Path, base_sha: str, path: str) -> bool:
|
|
204
|
+
return git(work, "cat-file", "-e", f"{base_sha}:{path}").returncode == 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def remove_worktree_path(path: pathlib.Path) -> None:
|
|
208
|
+
if path.is_symlink() or path.is_file():
|
|
209
|
+
path.unlink()
|
|
210
|
+
elif path.is_dir():
|
|
211
|
+
shutil.rmtree(path)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def revert_offender(work: pathlib.Path, base_sha: str, path: str) -> tuple[bool, str | None, bool]:
|
|
215
|
+
existed_at_base = path_exists_at_base(work, base_sha, path)
|
|
216
|
+
if existed_at_base:
|
|
217
|
+
proc = git(work, "checkout", base_sha, "--", path)
|
|
218
|
+
if proc.returncode != 0:
|
|
219
|
+
return (False, proc.stderr.strip() or proc.stdout.strip(), True)
|
|
220
|
+
return (True, None, True)
|
|
221
|
+
|
|
222
|
+
proc = git(work, "rm", "-f", "--ignore-unmatch", "--", path)
|
|
223
|
+
target = work / path
|
|
224
|
+
try:
|
|
225
|
+
if target.exists() or target.is_symlink():
|
|
226
|
+
remove_worktree_path(target)
|
|
227
|
+
except OSError as e:
|
|
228
|
+
return (False, str(e), False)
|
|
229
|
+
if proc.returncode != 0:
|
|
230
|
+
cached = git(work, "rm", "-f", "--cached", "--ignore-unmatch", "--", path)
|
|
231
|
+
if cached.returncode != 0:
|
|
232
|
+
detail = proc.stderr.strip() or cached.stderr.strip() or proc.stdout.strip()
|
|
233
|
+
return (False, detail, False)
|
|
234
|
+
return (True, None, False)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def run_gate(work: pathlib.Path, devlyn_dir: pathlib.Path) -> int:
|
|
238
|
+
state_path = devlyn_dir / "pipeline.state.json"
|
|
239
|
+
findings_path = devlyn_dir / FINDINGS_NAME
|
|
240
|
+
try:
|
|
241
|
+
state = read_state(state_path)
|
|
242
|
+
findings_path.unlink(missing_ok=True)
|
|
243
|
+
if state.get("mode") == "verify-only":
|
|
244
|
+
write_summary(devlyn_dir, {"mode": "verify-only", "skipped": True, "exit": 0})
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
base_sha = ((state.get("base_ref") or {}).get("sha") or "").strip()
|
|
248
|
+
if not base_sha:
|
|
249
|
+
raise Malformed("pipeline.state.json must include non-empty base_ref.sha")
|
|
250
|
+
ensure_commit(work, base_sha, "base_ref.sha")
|
|
251
|
+
surface = load_authorized_surface(devlyn_dir)
|
|
252
|
+
cleanup_paths = cleanup_window(work, state)
|
|
253
|
+
changed = changed_files(work, base_sha)
|
|
254
|
+
devlyn_prefix = devlyn_relative_prefix(work, devlyn_dir)
|
|
255
|
+
checked = sorted(path for path in changed if not is_under_prefix(path, devlyn_prefix))
|
|
256
|
+
except Malformed as e:
|
|
257
|
+
findings_path.unlink(missing_ok=True)
|
|
258
|
+
write_findings(devlyn_dir, [malformed_finding(e)])
|
|
259
|
+
write_summary(devlyn_dir, {"exit": 1, "malformed": str(e)})
|
|
260
|
+
return 1
|
|
261
|
+
|
|
262
|
+
offenders = sorted(
|
|
263
|
+
path for path in checked
|
|
264
|
+
if (
|
|
265
|
+
not SPEC_VERIFY.path_matches_surface(path, surface)
|
|
266
|
+
and path not in cleanup_paths
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
if not offenders:
|
|
270
|
+
findings_path.unlink(missing_ok=True)
|
|
271
|
+
write_summary(devlyn_dir, {
|
|
272
|
+
"mode": state.get("mode"),
|
|
273
|
+
"checked": len(checked),
|
|
274
|
+
"offenders": 0,
|
|
275
|
+
"exit": 0,
|
|
276
|
+
})
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
findings: list[dict] = []
|
|
280
|
+
failed = False
|
|
281
|
+
reverted = 0
|
|
282
|
+
revert_failed = 0
|
|
283
|
+
for seq, path in enumerate(offenders, start=1):
|
|
284
|
+
ok, detail, existed_at_base = revert_offender(work, base_sha, path)
|
|
285
|
+
status = "reverted" if ok else "revert-failed"
|
|
286
|
+
failed = failed or not ok
|
|
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_code = 2 if failed else 0
|
|
309
|
+
write_summary(devlyn_dir, {
|
|
310
|
+
"mode": state.get("mode"),
|
|
311
|
+
"checked": len(checked),
|
|
312
|
+
"offenders": len(offenders),
|
|
313
|
+
"reverted": reverted,
|
|
314
|
+
"revert_failed": revert_failed,
|
|
315
|
+
"exit": exit_code,
|
|
316
|
+
})
|
|
317
|
+
return exit_code
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def write_text(path: pathlib.Path, text: str) -> None:
|
|
321
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
path.write_text(text, encoding="utf-8")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def write_state(devlyn: pathlib.Path, state: dict) -> None:
|
|
326
|
+
write_text(devlyn / "pipeline.state.json", json.dumps(state, indent=2) + "\n")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def read_findings(devlyn: pathlib.Path) -> list[dict]:
|
|
330
|
+
path = devlyn / FINDINGS_NAME
|
|
331
|
+
return [SPEC_VERIFY.loads_strict_json(line) for line in path.read_text().splitlines() if line.strip()]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def read_summary(devlyn: pathlib.Path) -> dict:
|
|
335
|
+
return SPEC_VERIFY.loads_strict_json((devlyn / SUMMARY_NAME).read_text(encoding="utf-8"))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def assert_summary(devlyn: pathlib.Path, expected: dict) -> None:
|
|
339
|
+
summary = read_summary(devlyn)
|
|
340
|
+
for key, value in expected.items():
|
|
341
|
+
assert summary.get(key) == value, summary
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def make_fixture(root: pathlib.Path, name: str, *, mode: str = "full") -> tuple[pathlib.Path, pathlib.Path, str]:
|
|
345
|
+
work = root / name
|
|
346
|
+
work.mkdir()
|
|
347
|
+
git_check(work, "init", "-q")
|
|
348
|
+
write_text(work / "src" / "app.txt", "base app\n")
|
|
349
|
+
write_text(work / "notes.txt", "base notes\n")
|
|
350
|
+
write_text(work / "cleanable.txt", "base cleanable\n")
|
|
351
|
+
git_check(work, "add", "-A")
|
|
352
|
+
git_check(work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "base")
|
|
353
|
+
base_sha = git_check(work, "rev-parse", "HEAD")
|
|
354
|
+
devlyn = work / ".devlyn"
|
|
355
|
+
write_text(
|
|
356
|
+
devlyn / "plan.md",
|
|
357
|
+
"# PLAN\n\n<!-- devlyn:authorized-surface -->\n## Files to touch\n\n"
|
|
358
|
+
"- `src/app.txt` (edit): implement the requested change.\n\n"
|
|
359
|
+
"```json\n"
|
|
360
|
+
'{"authorized_surface": ["src/app.txt"]}\n'
|
|
361
|
+
"```\n",
|
|
362
|
+
)
|
|
363
|
+
write_state(devlyn, {
|
|
364
|
+
"mode": mode,
|
|
365
|
+
"base_ref": {"sha": base_sha},
|
|
366
|
+
"phases": {"cleanup": None},
|
|
367
|
+
})
|
|
368
|
+
return (work, devlyn, base_sha)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def checked_run_gate(work: pathlib.Path, devlyn: pathlib.Path) -> int:
|
|
372
|
+
rc = run_gate(work, devlyn)
|
|
373
|
+
assert not any(path.name == "__pycache__" for path in work.rglob("__pycache__"))
|
|
374
|
+
return rc
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def self_test() -> int:
|
|
378
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
379
|
+
root = pathlib.Path(tmp)
|
|
380
|
+
|
|
381
|
+
work, devlyn, _base = make_fixture(root, "conforming")
|
|
382
|
+
write_text(work / "src" / "app.txt", "changed app\n")
|
|
383
|
+
assert checked_run_gate(work, devlyn) == 0
|
|
384
|
+
assert not (devlyn / FINDINGS_NAME).exists()
|
|
385
|
+
assert_summary(devlyn, {"mode": "full", "checked": 1, "offenders": 0, "exit": 0})
|
|
386
|
+
|
|
387
|
+
work, devlyn, _base = make_fixture(root, "tracked-mutation")
|
|
388
|
+
write_text(work / "notes.txt", "polluted\n")
|
|
389
|
+
assert checked_run_gate(work, devlyn) == 0
|
|
390
|
+
assert (work / "notes.txt").read_text(encoding="utf-8") == "base notes\n"
|
|
391
|
+
findings = read_findings(devlyn)
|
|
392
|
+
assert findings[0]["rule_id"] == "scope.finish-unaudited-file"
|
|
393
|
+
assert findings[0]["status"] == "reverted"
|
|
394
|
+
assert_summary(devlyn, {
|
|
395
|
+
"mode": "full", "checked": 1, "offenders": 1,
|
|
396
|
+
"reverted": 1, "revert_failed": 0, "exit": 0,
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
work, devlyn, _base = make_fixture(root, "added-file")
|
|
400
|
+
write_text(work / "runtime.txt", "late\n")
|
|
401
|
+
git_check(work, "add", "runtime.txt")
|
|
402
|
+
assert checked_run_gate(work, devlyn) == 0
|
|
403
|
+
assert not (work / "runtime.txt").exists()
|
|
404
|
+
assert "runtime.txt" not in git_check(work, "diff", "--name-only", _base).splitlines()
|
|
405
|
+
assert_summary(devlyn, {
|
|
406
|
+
"mode": "full", "checked": 1, "offenders": 1,
|
|
407
|
+
"reverted": 1, "revert_failed": 0, "exit": 0,
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
work, devlyn, _base = make_fixture(root, "devlyn-owned-tracked-mutation")
|
|
411
|
+
archived = devlyn / "runs" / "prior" / "pipeline.state.json"
|
|
412
|
+
write_text(archived, "tracked devlyn state\n")
|
|
413
|
+
git_check(work, "add", archived.relative_to(work).as_posix())
|
|
414
|
+
git_check(work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "track devlyn state")
|
|
415
|
+
write_text(archived, "mutated devlyn state\n")
|
|
416
|
+
write_text(work / "notes.txt", "polluted\n")
|
|
417
|
+
assert checked_run_gate(work, devlyn) == 0
|
|
418
|
+
assert archived.read_text(encoding="utf-8") == "mutated devlyn state\n"
|
|
419
|
+
assert (work / "notes.txt").read_text(encoding="utf-8") == "base notes\n"
|
|
420
|
+
findings = read_findings(devlyn)
|
|
421
|
+
assert [finding["file"] for finding in findings] == ["notes.txt"]
|
|
422
|
+
assert findings[0]["status"] == "reverted"
|
|
423
|
+
assert_summary(devlyn, {
|
|
424
|
+
"mode": "full", "checked": 1, "offenders": 1,
|
|
425
|
+
"reverted": 1, "revert_failed": 0, "exit": 0,
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
work, devlyn, base = make_fixture(root, "cleanup-window")
|
|
429
|
+
write_text(work / "cleanable.txt", "cleanup changed\n")
|
|
430
|
+
git_check(work, "add", "cleanable.txt")
|
|
431
|
+
git_check(work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "cleanup")
|
|
432
|
+
post_sha = git_check(work, "rev-parse", "HEAD")
|
|
433
|
+
write_state(devlyn, {
|
|
434
|
+
"mode": "full",
|
|
435
|
+
"base_ref": {"sha": base},
|
|
436
|
+
"phases": {"cleanup": {"verdict": "PASS", "pre_sha": base, "post_sha": post_sha}},
|
|
437
|
+
})
|
|
438
|
+
assert checked_run_gate(work, devlyn) == 0
|
|
439
|
+
assert not (devlyn / FINDINGS_NAME).exists()
|
|
440
|
+
assert_summary(devlyn, {"mode": "full", "checked": 1, "offenders": 0, "exit": 0})
|
|
441
|
+
|
|
442
|
+
work, devlyn, base = make_fixture(root, "cleanup-malformed")
|
|
443
|
+
write_state(devlyn, {
|
|
444
|
+
"mode": "full",
|
|
445
|
+
"base_ref": {"sha": base},
|
|
446
|
+
"phases": {"cleanup": {"verdict": "PASS", "pre_sha": base}},
|
|
447
|
+
})
|
|
448
|
+
assert checked_run_gate(work, devlyn) == 1
|
|
449
|
+
assert read_findings(devlyn)[0]["rule_id"] == "scope.finish-gate-malformed"
|
|
450
|
+
summary = read_summary(devlyn)
|
|
451
|
+
assert summary["exit"] == 1, summary
|
|
452
|
+
assert "phases.cleanup with a verdict" in summary["malformed"], summary
|
|
453
|
+
|
|
454
|
+
work, devlyn, _base = make_fixture(root, "verify-only", mode="verify-only")
|
|
455
|
+
(devlyn / "plan.md").unlink()
|
|
456
|
+
write_findings(devlyn, [make_finding(
|
|
457
|
+
1,
|
|
458
|
+
"scope.finish-unaudited-file",
|
|
459
|
+
"stale",
|
|
460
|
+
"stale.txt",
|
|
461
|
+
status="reverted",
|
|
462
|
+
criterion_ref="plan.md/authorized_surface",
|
|
463
|
+
fix_hint="stale",
|
|
464
|
+
)])
|
|
465
|
+
write_state(devlyn, {"mode": "verify-only", "phases": {}})
|
|
466
|
+
assert checked_run_gate(work, devlyn) == 0
|
|
467
|
+
assert not (devlyn / FINDINGS_NAME).exists()
|
|
468
|
+
assert read_summary(devlyn) == {"exit": 0, "mode": "verify-only", "skipped": True}
|
|
469
|
+
|
|
470
|
+
work, devlyn, _base = make_fixture(root, "deleted-file")
|
|
471
|
+
(work / "notes.txt").unlink()
|
|
472
|
+
assert checked_run_gate(work, devlyn) == 0
|
|
473
|
+
assert (work / "notes.txt").read_text(encoding="utf-8") == "base notes\n"
|
|
474
|
+
assert read_findings(devlyn)[0]["status"] == "reverted"
|
|
475
|
+
assert_summary(devlyn, {
|
|
476
|
+
"mode": "full", "checked": 1, "offenders": 1,
|
|
477
|
+
"reverted": 1, "revert_failed": 0, "exit": 0,
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
work, devlyn, _base = make_fixture(root, "git-diff-failure")
|
|
481
|
+
original_git = globals()["git"]
|
|
482
|
+
|
|
483
|
+
def failing_git(work_arg: pathlib.Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
484
|
+
if args[:2] == ("diff", "--name-only"):
|
|
485
|
+
return subprocess.CompletedProcess(["git", *args], 128, "", "simulated diff failure")
|
|
486
|
+
return original_git(work_arg, *args)
|
|
487
|
+
|
|
488
|
+
globals()["git"] = failing_git
|
|
489
|
+
try:
|
|
490
|
+
assert checked_run_gate(work, devlyn) == 1
|
|
491
|
+
finding = read_findings(devlyn)[0]
|
|
492
|
+
assert finding["rule_id"] == "scope.finish-gate-malformed"
|
|
493
|
+
assert "cannot compute finish-gate changed files" in finding["message"]
|
|
494
|
+
summary = read_summary(devlyn)
|
|
495
|
+
assert summary["exit"] == 1, summary
|
|
496
|
+
assert "cannot compute finish-gate changed files" in summary["malformed"], summary
|
|
497
|
+
finally:
|
|
498
|
+
globals()["git"] = original_git
|
|
499
|
+
|
|
500
|
+
return 0
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def main() -> int:
|
|
504
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
505
|
+
ap.add_argument("--devlyn-dir", default=".devlyn")
|
|
506
|
+
ap.add_argument("--self-test", action="store_true")
|
|
507
|
+
args = ap.parse_args()
|
|
508
|
+
if args.self_test:
|
|
509
|
+
return self_test()
|
|
510
|
+
devlyn_dir = pathlib.Path(args.devlyn_dir)
|
|
511
|
+
if not devlyn_dir.is_absolute():
|
|
512
|
+
devlyn_dir = (pathlib.Path.cwd() / devlyn_dir).resolve()
|
|
513
|
+
return run_gate(devlyn_dir.parent, devlyn_dir)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
if __name__ == "__main__":
|
|
517
|
+
raise SystemExit(main())
|