devlyn-cli 3.1.1 → 3.1.2
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/config/skills/_shared/process-evidence.py +16 -1
- package/config/skills/_shared/resolve-bootstrap.py +29 -4
- package/config/skills/_shared/spec-verify-check.py +119 -10
- package/config/skills/_shared/task-complete.py +251 -15
- package/config/skills/devlyn:ideate/references/spec-template.md +1 -1
- package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/verify.md +9 -7
- package/config/skills/devlyn:resolve/references/state-schema.md +1 -1
- package/config/skills/devlyn:resolve/references/task-completion.md +27 -0
- package/package.json +1 -1
|
@@ -185,7 +185,7 @@ def _source_expected_path(work: pathlib.Path, state: dict) -> pathlib.Path | Non
|
|
|
185
185
|
if not isinstance(spec_path, str) or not spec_path:
|
|
186
186
|
raise EvidenceError("state.source.spec_path is missing")
|
|
187
187
|
spec_rel = _safe_relative_path(spec_path, "state.source.spec_path")
|
|
188
|
-
expected = work.joinpath(*spec_rel.parts).
|
|
188
|
+
expected = work.joinpath(*spec_rel.parts).with_name("spec.expected.json")
|
|
189
189
|
try:
|
|
190
190
|
expected.resolve().relative_to(work.resolve())
|
|
191
191
|
except (OSError, ValueError) as exc:
|
|
@@ -682,6 +682,21 @@ def self_test() -> int:
|
|
|
682
682
|
"cmd": "printf raw-out; printf raw-err >&2; exit 7",
|
|
683
683
|
"exit_code": 7, "stdout_contains": ["raw-outraw-err"],
|
|
684
684
|
})
|
|
685
|
+
spec_dir = work / "docs"
|
|
686
|
+
spec_dir.mkdir()
|
|
687
|
+
(spec_dir / "X.md").write_text("# Named spec\n", encoding="utf-8")
|
|
688
|
+
expected = spec_dir / "spec.expected.json"
|
|
689
|
+
expected.write_text(json.dumps({
|
|
690
|
+
"process_evidence": [obligation],
|
|
691
|
+
"verification_commands": [{"cmd": "printf required"}],
|
|
692
|
+
}), encoding="utf-8")
|
|
693
|
+
(spec_dir / "X.expected.json").write_text("{}\n", encoding="utf-8")
|
|
694
|
+
named_state = {**state, "source": {"type": "spec", "spec_path": "docs/X.md"}}
|
|
695
|
+
assert declared_obligations(work, named_state, "implement") == [obligation]
|
|
696
|
+
assert mechanical_evidence_required(work, named_state)
|
|
697
|
+
expected.unlink()
|
|
698
|
+
assert declared_obligations(work, named_state, "implement") == []
|
|
699
|
+
assert not mechanical_evidence_required(work, named_state)
|
|
685
700
|
entry = capture_process(work, manifest, state["run_id"], "implement", 0, obligation)
|
|
686
701
|
assert entry["expectation_met"]
|
|
687
702
|
assert (manifest.parent / "red-first.stdout").read_bytes() == b"raw-out"
|
|
@@ -282,16 +282,13 @@ def init_spec_source(
|
|
|
282
282
|
block("BLOCKED:invalid-flags", f"spec unreadable: {raw_path}: {exc}")
|
|
283
283
|
helper = shared_dir / "spec-verify-check.py"
|
|
284
284
|
expected = path.with_name("spec.expected.json")
|
|
285
|
-
if expected.is_file():
|
|
286
|
-
run_checked([sys.executable, str(helper), "--check-expected", str(expected)], cwd)
|
|
287
|
-
else:
|
|
288
|
-
run_checked([sys.executable, str(helper), "--check", str(path)], cwd)
|
|
289
285
|
module = load_spec_helper(shared_dir)
|
|
290
286
|
if expected.is_file():
|
|
291
287
|
found, _staged, error, _expected_path, _data = module.stage_from_expected(path, staging_dir)
|
|
292
288
|
if not found or error:
|
|
293
289
|
block("BLOCKED:invalid-flags", error or f"expected contract not found: {expected}")
|
|
294
290
|
else:
|
|
291
|
+
run_checked([sys.executable, str(helper), "--check", str(path)], cwd)
|
|
295
292
|
_staged, error = module.stage_from_source(path, staging_dir)
|
|
296
293
|
if error:
|
|
297
294
|
block("BLOCKED:invalid-flags", error)
|
|
@@ -1210,6 +1207,34 @@ def self_test() -> int:
|
|
|
1210
1207
|
subprocess.run(["git", "restore", "app.py"], cwd=work, check=True)
|
|
1211
1208
|
print("PASS bootstrap self-test patch lifecycle: full-mode removal + dirty verify-only exact capture")
|
|
1212
1209
|
|
|
1210
|
+
for valid_named in (True, False):
|
|
1211
|
+
named_work = root / f"named-spec-{valid_named}"
|
|
1212
|
+
init_repo(named_work)
|
|
1213
|
+
named_spec = named_work / "X.md"
|
|
1214
|
+
named_spec.write_text(
|
|
1215
|
+
"---\ncomplexity: " + ("medium" if valid_named else "hihg") + "\n---\n# Named spec\n",
|
|
1216
|
+
encoding="utf-8",
|
|
1217
|
+
)
|
|
1218
|
+
(named_work / "spec.md").write_text(
|
|
1219
|
+
"---\ncomplexity: " + ("hihg" if valid_named else "medium") + "\n---\n# Unrelated spec\n",
|
|
1220
|
+
encoding="utf-8",
|
|
1221
|
+
)
|
|
1222
|
+
(named_work / "spec.expected.json").write_text(json.dumps({
|
|
1223
|
+
"verification_commands": [{"cmd": "printf named-contract"}],
|
|
1224
|
+
}), encoding="utf-8")
|
|
1225
|
+
try:
|
|
1226
|
+
result = bootstrap(["--spec", "X.md"], named_work, script_shared)
|
|
1227
|
+
except BootstrapBlocked as exc:
|
|
1228
|
+
assert not valid_named and exc.reason == "BLOCKED:invalid-flags", exc
|
|
1229
|
+
assert "frontmatter complexity" in exc.detail, exc.detail
|
|
1230
|
+
assert not (named_work / ".devlyn").exists()
|
|
1231
|
+
else:
|
|
1232
|
+
assert valid_named, "invalid named spec was accepted via unrelated spec.md"
|
|
1233
|
+
assert result["source"]["spec_path"] == "X.md"
|
|
1234
|
+
assert result["source"]["spec_sha256"] == sha256(named_spec.read_bytes())
|
|
1235
|
+
staged = strict_json((named_work / ".devlyn/spec-verify.json").read_text(encoding="utf-8"))
|
|
1236
|
+
assert staged["verification_commands"] == [{"cmd": "printf named-contract"}]
|
|
1237
|
+
|
|
1213
1238
|
dirty_work = root / "dirty-repo"
|
|
1214
1239
|
init_repo(dirty_work)
|
|
1215
1240
|
bootstrap(["clean", "baseline"], dirty_work, script_shared)
|
|
@@ -844,10 +844,21 @@ def validate_expected_shape(data) -> str | None:
|
|
|
844
844
|
return None
|
|
845
845
|
|
|
846
846
|
|
|
847
|
-
def
|
|
847
|
+
def validate_inline_shape(data: object) -> str | None:
|
|
848
|
+
if isinstance(data, dict):
|
|
849
|
+
unknown = sorted(set(data) - {"verification_commands"})
|
|
850
|
+
if unknown:
|
|
851
|
+
return (
|
|
852
|
+
f"unsupported inline key(s): {', '.join(unknown)}; inline carriers "
|
|
853
|
+
"support only verification_commands. Encode these checks as commands, "
|
|
854
|
+
"or use a real spec with sibling spec.expected.json."
|
|
855
|
+
)
|
|
856
|
+
return validate_shape(data) or validate_expected_shape(data)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def validate_expected_against_sibling_spec(spec_path: Path, data: object) -> str | None:
|
|
848
860
|
if not isinstance(data, dict):
|
|
849
861
|
return None
|
|
850
|
-
spec_path = expected_path.with_name("spec.md")
|
|
851
862
|
if not spec_path.is_file():
|
|
852
863
|
return None
|
|
853
864
|
try:
|
|
@@ -881,8 +892,7 @@ def validate_expected_against_sibling_spec(expected_path: Path, data: object) ->
|
|
|
881
892
|
)
|
|
882
893
|
|
|
883
894
|
|
|
884
|
-
def validate_sibling_spec_complexity(
|
|
885
|
-
spec_path = expected_path.with_name("spec.md")
|
|
895
|
+
def validate_sibling_spec_complexity(spec_path: Path) -> str | None:
|
|
886
896
|
if not spec_path.is_file():
|
|
887
897
|
return None
|
|
888
898
|
try:
|
|
@@ -1208,7 +1218,7 @@ def stage_from_source(md: Path, devlyn_dir: Path) -> tuple[bool, str | None]:
|
|
|
1208
1218
|
data = loads_strict_json(block)
|
|
1209
1219
|
except ValueError as e:
|
|
1210
1220
|
return (False, f"`<!-- devlyn:verification -->` ```json``` block in {md} has invalid JSON: {e}")
|
|
1211
|
-
err =
|
|
1221
|
+
err = validate_inline_shape(data)
|
|
1212
1222
|
if err:
|
|
1213
1223
|
return (False, f"`<!-- devlyn:verification -->` ```json``` block in {md}: {err}")
|
|
1214
1224
|
normalized = {"verification_commands": data["verification_commands"]}
|
|
@@ -1236,6 +1246,9 @@ def stage_from_expected(
|
|
|
1236
1246
|
if err:
|
|
1237
1247
|
return (True, False, err, expected_path, None)
|
|
1238
1248
|
assert data is not None
|
|
1249
|
+
err = validate_sibling_spec_complexity(md) or validate_expected_against_sibling_spec(md, data)
|
|
1250
|
+
if err:
|
|
1251
|
+
return (True, False, f"{expected_path}: {err}", expected_path, None)
|
|
1239
1252
|
commands = data.get("verification_commands")
|
|
1240
1253
|
if not commands:
|
|
1241
1254
|
spec_path = devlyn_dir / "spec-verify.json"
|
|
@@ -1892,7 +1905,7 @@ def run_check_mode(md_path: Path) -> int:
|
|
|
1892
1905
|
file=sys.stderr,
|
|
1893
1906
|
)
|
|
1894
1907
|
return 2
|
|
1895
|
-
err =
|
|
1908
|
+
err = validate_inline_shape(data)
|
|
1896
1909
|
if err:
|
|
1897
1910
|
print(f"[spec-verify --check] {md_path}: shape error: {err}", file=sys.stderr)
|
|
1898
1911
|
return 2
|
|
@@ -1915,11 +1928,11 @@ def run_check_expected_mode(expected_path: Path) -> int:
|
|
|
1915
1928
|
if err:
|
|
1916
1929
|
print(f"[spec-verify --check-expected] {expected_path}: shape error: {err}", file=sys.stderr)
|
|
1917
1930
|
return 2
|
|
1918
|
-
complexity_err = validate_sibling_spec_complexity(expected_path)
|
|
1931
|
+
complexity_err = validate_sibling_spec_complexity(expected_path.with_name("spec.md"))
|
|
1919
1932
|
if complexity_err:
|
|
1920
1933
|
print(f"[spec-verify --check-expected] {expected_path}: shape error: {complexity_err}", file=sys.stderr)
|
|
1921
1934
|
return 2
|
|
1922
|
-
sibling_err = validate_expected_against_sibling_spec(expected_path, _data)
|
|
1935
|
+
sibling_err = validate_expected_against_sibling_spec(expected_path.with_name("spec.md"), _data)
|
|
1923
1936
|
if sibling_err:
|
|
1924
1937
|
print(f"[spec-verify --check-expected] {expected_path}: shape error: {sibling_err}", file=sys.stderr)
|
|
1925
1938
|
return 2
|
|
@@ -3691,6 +3704,54 @@ def run_self_test() -> int:
|
|
|
3691
3704
|
print("generated criteria carrier was not staged into .devlyn/spec-verify.json", file=sys.stderr)
|
|
3692
3705
|
return 1
|
|
3693
3706
|
|
|
3707
|
+
inline_marker = generated_user / "inline-command-ran"
|
|
3708
|
+
inline_command = "printf bad > inline-command-ran; printf bad"
|
|
3709
|
+
for source_type in ("generated", "spec"):
|
|
3710
|
+
for contract, diagnostic in (
|
|
3711
|
+
({"verification_commands": [{"cmd": inline_command}],
|
|
3712
|
+
"required_files": ["missing.txt"]}, "unsupported inline key(s): required_files"),
|
|
3713
|
+
({"verification_commands": [{"cmd": inline_command,
|
|
3714
|
+
"stdout_not_contians": ["bad"]}]}, "unknown key(s): stdout_not_contians"),
|
|
3715
|
+
({"verification_commands": [{"cmd": inline_command,
|
|
3716
|
+
"stdout_not_contains": ["bad"]}]}, None),
|
|
3717
|
+
):
|
|
3718
|
+
inline_marker.unlink(missing_ok=True)
|
|
3719
|
+
(generated_devlyn / output_findings_name()).unlink(missing_ok=True)
|
|
3720
|
+
generated_criteria.write_text(
|
|
3721
|
+
"# Inline constraints\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n"
|
|
3722
|
+
+ json.dumps(contract) + "\n```\n", encoding="utf-8",
|
|
3723
|
+
)
|
|
3724
|
+
prefix = "criteria" if source_type == "generated" else "spec"
|
|
3725
|
+
(generated_devlyn / "pipeline.state.json").write_text(json.dumps({
|
|
3726
|
+
"source": {
|
|
3727
|
+
"type": source_type,
|
|
3728
|
+
f"{prefix}_path": str(generated_criteria),
|
|
3729
|
+
f"{prefix}_sha256": hashlib.sha256(generated_criteria.read_bytes()).hexdigest(),
|
|
3730
|
+
}
|
|
3731
|
+
}), encoding="utf-8")
|
|
3732
|
+
# A stale valid carrier must not bypass malformed source validation.
|
|
3733
|
+
(generated_devlyn / "spec-verify.json").write_text(json.dumps({
|
|
3734
|
+
"verification_commands": [{"cmd": inline_command}]
|
|
3735
|
+
}), encoding="utf-8")
|
|
3736
|
+
inline_check = subprocess.run(
|
|
3737
|
+
[sys.executable, script_path, "--check", str(generated_criteria)],
|
|
3738
|
+
cwd=generated_user, capture_output=True, text=True, encoding="utf-8",
|
|
3739
|
+
)
|
|
3740
|
+
inline_run = subprocess.run(
|
|
3741
|
+
[sys.executable, script_path], cwd=generated_user,
|
|
3742
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
3743
|
+
)
|
|
3744
|
+
assert inline_check.returncode == (2 if diagnostic else 0)
|
|
3745
|
+
assert inline_run.returncode == 1
|
|
3746
|
+
if diagnostic:
|
|
3747
|
+
assert diagnostic in inline_check.stderr and diagnostic in inline_run.stderr
|
|
3748
|
+
assert not inline_marker.exists(), "malformed inline constraint executed a command"
|
|
3749
|
+
else:
|
|
3750
|
+
assert inline_marker.exists(), "valid inline output guard was not executed"
|
|
3751
|
+
assert "correctness.spec-literal-mismatch" in (
|
|
3752
|
+
generated_devlyn / output_findings_name()
|
|
3753
|
+
).read_text(encoding="utf-8")
|
|
3754
|
+
|
|
3694
3755
|
generated_criteria.write_text(
|
|
3695
3756
|
"# Criteria\n\n## Verification\n\n- generated criteria omitted its machine-readable carrier.\n",
|
|
3696
3757
|
encoding="utf-8",
|
|
@@ -3750,6 +3811,54 @@ def run_self_test() -> int:
|
|
|
3750
3811
|
print("sibling spec.expected.json was not staged into .devlyn/spec-verify.json", file=sys.stderr)
|
|
3751
3812
|
return 1
|
|
3752
3813
|
|
|
3814
|
+
pure_prose = "# Design\n\n<!-- devlyn:verification -->\n## Verification\n\n- No runtime verification commands.\n"
|
|
3815
|
+
stale_command = {"verification_commands": [{"cmd": "printf unexpected-command"}]}
|
|
3816
|
+
pure_inline = pure_prose + "\n```json\n" + json.dumps(stale_command) + "\n```\n"
|
|
3817
|
+
for name, filename, source, contract, expected_rule in (
|
|
3818
|
+
("pure-prose", "spec.md", pure_prose, {"pure_design": True}, None),
|
|
3819
|
+
("pure-inline", "design-notes.md", pure_inline, {"pure_design": True, "verification_commands": []}, None),
|
|
3820
|
+
("empty-runtime", "design-notes.md", pure_inline, {"verification_commands": []}, "correctness.spec-verify-malformed"),
|
|
3821
|
+
("contradictory-pure", "spec.md", pure_inline, {"pure_design": True, **stale_command}, "correctness.spec-verify-malformed"),
|
|
3822
|
+
("pure-required-file", "spec.md", pure_inline, {"pure_design": True, "required_files": ["missing.md"]}, "correctness.required-file-missing"),
|
|
3823
|
+
):
|
|
3824
|
+
case_root = work / name
|
|
3825
|
+
case_root.mkdir()
|
|
3826
|
+
case_devlyn = case_root / ".devlyn"
|
|
3827
|
+
case_devlyn.mkdir()
|
|
3828
|
+
case_spec = case_root / filename
|
|
3829
|
+
case_spec.write_text(source, encoding="utf-8")
|
|
3830
|
+
(case_root / "spec.expected.json").write_text(json.dumps(contract), encoding="utf-8")
|
|
3831
|
+
(case_devlyn / "spec-verify.json").write_text(json.dumps(stale_command), encoding="utf-8")
|
|
3832
|
+
(case_devlyn / "pipeline.state.json").write_text(json.dumps({
|
|
3833
|
+
"source": {"type": "spec", "spec_path": str(case_spec)},
|
|
3834
|
+
}), encoding="utf-8")
|
|
3835
|
+
case_run = subprocess.run(
|
|
3836
|
+
[sys.executable, script_path], cwd=case_root,
|
|
3837
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
3838
|
+
)
|
|
3839
|
+
if case_run.returncode != (1 if expected_rule else 0):
|
|
3840
|
+
print(f"{name}: sibling execution returned {case_run.returncode}: {case_run.stderr}", file=sys.stderr)
|
|
3841
|
+
return 1
|
|
3842
|
+
case_findings = [
|
|
3843
|
+
loads_strict_json(line) for line in
|
|
3844
|
+
(case_devlyn / output_findings_name()).read_text(encoding="utf-8").splitlines()
|
|
3845
|
+
]
|
|
3846
|
+
if [finding["rule_id"] for finding in case_findings] != ([expected_rule] if expected_rule else []):
|
|
3847
|
+
print(f"{name}: unexpected sibling findings: {case_findings}", file=sys.stderr)
|
|
3848
|
+
return 1
|
|
3849
|
+
case_results = case_devlyn / "spec-verify.results.json"
|
|
3850
|
+
if expected_rule == "correctness.spec-verify-malformed":
|
|
3851
|
+
if case_results.exists():
|
|
3852
|
+
print(f"{name}: malformed sibling reached command execution", file=sys.stderr)
|
|
3853
|
+
return 1
|
|
3854
|
+
elif (
|
|
3855
|
+
loads_strict_json(case_results.read_text(encoding="utf-8"))
|
|
3856
|
+
!= {"commands": [], "process_evidence": None}
|
|
3857
|
+
or (case_devlyn / "spec-verify.json").exists()
|
|
3858
|
+
):
|
|
3859
|
+
print(f"{name}: pure-design sibling ran or retained a stale command", file=sys.stderr)
|
|
3860
|
+
return 1
|
|
3861
|
+
|
|
3753
3862
|
malformed = work / "malformed-sibling"
|
|
3754
3863
|
malformed.mkdir()
|
|
3755
3864
|
malformed_devlyn = malformed / ".devlyn"
|
|
@@ -4915,8 +5024,8 @@ def main() -> int:
|
|
|
4915
5024
|
print(f"[spec-verify] carrier malformed: {expected_error}", file=sys.stderr)
|
|
4916
5025
|
write_malformed_finding(devlyn_dir, expected_error, expected_path)
|
|
4917
5026
|
return 1
|
|
4918
|
-
if
|
|
4919
|
-
staged, error = (
|
|
5027
|
+
if expected_found:
|
|
5028
|
+
staged, error = (expected_staged, None)
|
|
4920
5029
|
else:
|
|
4921
5030
|
staged, error = stage_from_source(source_md, devlyn_dir)
|
|
4922
5031
|
else:
|
|
@@ -204,6 +204,9 @@ def allocate(args):
|
|
|
204
204
|
path = directory / "receipt.json"
|
|
205
205
|
receipt["id"] = key
|
|
206
206
|
receipt["recovery_ref"] = "refs/devlyn/completed/" + key
|
|
207
|
+
scratch = directory / "scratch"
|
|
208
|
+
scratch.mkdir()
|
|
209
|
+
receipt["scratch_identity"] = workspace_identity(scratch, directory)
|
|
207
210
|
# Persist prospective intent BEFORE native creation. An interrupted allocation
|
|
208
211
|
# stays visibly incomplete; a later call may not adopt whatever now exists.
|
|
209
212
|
atomic_json(path, receipt)
|
|
@@ -215,7 +218,7 @@ def allocate(args):
|
|
|
215
218
|
receipt["worktree_identity"] = workspace_identity(target, Path(receipt["worktree_gitdir"]))
|
|
216
219
|
receipt["allocation"] = "owned"
|
|
217
220
|
atomic_json(path, receipt)
|
|
218
|
-
return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target)}
|
|
221
|
+
return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch)}
|
|
219
222
|
|
|
220
223
|
|
|
221
224
|
def pipeline_acceptance(work, acceptance, files, directory):
|
|
@@ -269,7 +272,7 @@ def pipeline_acceptance(work, acceptance, files, directory):
|
|
|
269
272
|
require(evidence_module["bound_carrier_outcome"](reconstructed, carrier)["verdict"] == "PASS", "required process evidence failed")
|
|
270
273
|
source = state.get("source") or {}
|
|
271
274
|
if source.get("spec_path"):
|
|
272
|
-
expected = Path(source["spec_path"]).
|
|
275
|
+
expected = Path(source["spec_path"]).with_name("spec.expected.json")
|
|
273
276
|
original = safe_path(work, str(expected))
|
|
274
277
|
if original.exists():
|
|
275
278
|
dest = safe_path(reconstructed, str(expected))
|
|
@@ -342,7 +345,8 @@ def bind_acceptance(receipt, path, supplied):
|
|
|
342
345
|
require(committed.returncode == 0 and committed.stdout == source_file.read_bytes(), "source contract changed since accepted commit")
|
|
343
346
|
bound_digest = source.get(key.replace("_path", "_sha256"))
|
|
344
347
|
require(file_record(source_file)["sha256"] == bound_digest, "source contract differs from run binding")
|
|
345
|
-
|
|
348
|
+
source_path = Path(source[key])
|
|
349
|
+
expected = str(source_path.with_name("spec.expected.json") if key == "spec_path" else source_path.with_suffix(".expected.json"))
|
|
346
350
|
if safe_path(work, expected).exists():
|
|
347
351
|
paths.append(expected)
|
|
348
352
|
else:
|
|
@@ -437,6 +441,75 @@ def stopped_writers(work, *, linked):
|
|
|
437
441
|
raise CompletionError("writer observation unsupported on this platform; retain workspace")
|
|
438
442
|
|
|
439
443
|
|
|
444
|
+
def scratch_mounts():
|
|
445
|
+
if sys.platform.startswith("linux"):
|
|
446
|
+
rows = Path("/proc/self/mountinfo").read_text(encoding="utf-8").splitlines()
|
|
447
|
+
paths = []
|
|
448
|
+
for line in rows:
|
|
449
|
+
fields = line.split()
|
|
450
|
+
require(len(fields) >= 6, "cannot parse mount table; retain scratch")
|
|
451
|
+
paths.append(re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), fields[4]))
|
|
452
|
+
elif sys.platform == "darwin":
|
|
453
|
+
paths = []
|
|
454
|
+
for line in command(["mount"]).splitlines():
|
|
455
|
+
description, separator, options = line.rpartition(" (")
|
|
456
|
+
candidates = [description[match.end():] for match in re.finditer(" on ", description)]
|
|
457
|
+
require(separator and options.endswith(")") and candidates, "cannot parse mount table; retain scratch")
|
|
458
|
+
# Darwin prints literal paths. Consider every possible separator so
|
|
459
|
+
# ' on ' in a device or directory name can only cause retention.
|
|
460
|
+
paths.extend(candidates)
|
|
461
|
+
else:
|
|
462
|
+
raise CompletionError("mount observation unsupported; retain scratch")
|
|
463
|
+
return [Path(value).resolve() for value in paths]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def raise_walk_error(error):
|
|
467
|
+
raise error
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def clean_scratch(receipt, path, writers_stopped):
|
|
471
|
+
"""Empty the owned build directory; keep its identity across resume/reuse."""
|
|
472
|
+
scratch = path.parent / "scratch"
|
|
473
|
+
if "scratch_identity" not in receipt:
|
|
474
|
+
return {"status": "NOT_OWNED"}
|
|
475
|
+
require(writers_stopped, "stop/yield scratch writers, then use --writers-stopped")
|
|
476
|
+
require(scratch == scratch.resolve() and scratch.is_dir(), "owned scratch missing or redirected; retain and inspect")
|
|
477
|
+
identity = receipt["scratch_identity"]
|
|
478
|
+
require(workspace_identity(scratch, path.parent) == identity, "scratch directory was replaced; retain")
|
|
479
|
+
require(shutil.rmtree.avoids_symlink_attacks, "safe scratch removal unsupported on this platform; retain")
|
|
480
|
+
require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch contains a mounted filesystem; retain")
|
|
481
|
+
stopped_writers(scratch, linked=True)
|
|
482
|
+
size = 0
|
|
483
|
+
for directory, dirs, files in os.walk(scratch, followlinks=False, onerror=raise_walk_error):
|
|
484
|
+
require(not any(name.casefold() == ".git" for name in dirs + files), "scratch contains Git recovery data; move it to retained custody before cleanup")
|
|
485
|
+
for name in dirs + files:
|
|
486
|
+
item = Path(directory) / name
|
|
487
|
+
info = item.lstat()
|
|
488
|
+
require(info.st_dev == identity[0][0], "scratch contains a mounted filesystem; retain")
|
|
489
|
+
size += info.st_size if stat.S_ISREG(info.st_mode) else 0
|
|
490
|
+
stopped_writers(scratch, linked=True)
|
|
491
|
+
require(workspace_identity(scratch, path.parent) == identity, "scratch changed before removal; retain")
|
|
492
|
+
require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch mount appeared before removal; retain")
|
|
493
|
+
for item in scratch.iterdir():
|
|
494
|
+
if item.is_dir() and not item.is_symlink():
|
|
495
|
+
shutil.rmtree(item)
|
|
496
|
+
else:
|
|
497
|
+
item.unlink()
|
|
498
|
+
require(workspace_identity(scratch, path.parent) == identity and not any(scratch.iterdir()), "scratch changed during cleanup; retain and inspect")
|
|
499
|
+
result = {"status": "CLEAN", "logical_bytes_removed": size}
|
|
500
|
+
receipt["scratch_cleanup"] = result
|
|
501
|
+
atomic_json(path, receipt)
|
|
502
|
+
return result
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def clean_scratch_command(args):
|
|
506
|
+
path = Path(args.receipt).absolute()
|
|
507
|
+
with locked_receipt(path) as receipt:
|
|
508
|
+
result = clean_scratch(receipt, path, args.writers_stopped)
|
|
509
|
+
return {"status": "SCRATCH_CLEAN" if result["status"] == "CLEAN" else "SCRATCH_NOT_OWNED",
|
|
510
|
+
"receipt": str(path), "scratch_cleanup": result, "product_verdict_unchanged": True}
|
|
511
|
+
|
|
512
|
+
|
|
440
513
|
def cleanup(receipt, path, pr, writers_stopped):
|
|
441
514
|
require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
|
|
442
515
|
require(writers_stopped, "wait actual children, stop/yield known writers, then resume with --writers-stopped")
|
|
@@ -522,11 +595,27 @@ def locked_receipt(path):
|
|
|
522
595
|
|
|
523
596
|
def complete(args):
|
|
524
597
|
path = Path(args.receipt).absolute()
|
|
525
|
-
resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)]
|
|
598
|
+
resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)] +
|
|
599
|
+
(["--writers-stopped"] if args.writers_stopped else []))
|
|
526
600
|
with locked_receipt(path) as receipt:
|
|
527
601
|
def result(status):
|
|
528
|
-
|
|
529
|
-
|
|
602
|
+
scratch = {"status": "NOT_OWNED"}
|
|
603
|
+
if "scratch_identity" in receipt:
|
|
604
|
+
try:
|
|
605
|
+
scratch = clean_scratch(receipt, path, args.writers_stopped)
|
|
606
|
+
except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError) as error:
|
|
607
|
+
scratch = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
|
|
608
|
+
sys.executable, str(Path(__file__).resolve()), "clean-scratch", "--receipt", str(path), "--writers-stopped"])}
|
|
609
|
+
receipt["scratch_cleanup"] = scratch
|
|
610
|
+
try:
|
|
611
|
+
atomic_json(path, receipt)
|
|
612
|
+
except OSError as record_error:
|
|
613
|
+
scratch["record_error"] = str(record_error)
|
|
614
|
+
return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and scratch["status"] == "RETAINED" else status,
|
|
615
|
+
"delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
|
|
616
|
+
"acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True,
|
|
617
|
+
"scratch_path": str(path.parent / "scratch") if "scratch_identity" in receipt else None,
|
|
618
|
+
"scratch_cleanup": scratch}
|
|
530
619
|
if args.local_only or receipt.get("local_only"):
|
|
531
620
|
receipt["local_only"] = True
|
|
532
621
|
atomic_json(path, receipt)
|
|
@@ -610,20 +699,24 @@ def main():
|
|
|
610
699
|
completion.add_argument("--mode", choices=("auto", "pr"))
|
|
611
700
|
completion.add_argument("--local-only", "--no-push", action="store_true")
|
|
612
701
|
completion.add_argument("--writers-stopped", action="store_true")
|
|
702
|
+
scratch_cleanup = actions.add_parser("clean-scratch")
|
|
703
|
+
scratch_cleanup.add_argument("--receipt", required=True)
|
|
704
|
+
scratch_cleanup.add_argument("--writers-stopped", action="store_true")
|
|
613
705
|
args = parser.parse_args()
|
|
614
706
|
if args.self_test:
|
|
615
707
|
return self_test()
|
|
616
708
|
if args.action is None:
|
|
617
|
-
parser.error("allocate or
|
|
709
|
+
parser.error("allocate, complete or clean-scratch is required")
|
|
618
710
|
try:
|
|
619
|
-
result = allocate(args) if args.action == "allocate" else
|
|
711
|
+
result = (allocate(args) if args.action == "allocate" else
|
|
712
|
+
clean_scratch_command(args) if args.action == "clean-scratch" else complete(args))
|
|
620
713
|
print(json.dumps(result, sort_keys=True))
|
|
621
714
|
return 0
|
|
622
|
-
except (CompletionError, OSError, ValueError, KeyError, TypeError, SystemExit) as exc:
|
|
715
|
+
except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError, SystemExit) as exc:
|
|
623
716
|
result = {"status": "BLOCKED", "reason": str(exc)}
|
|
624
|
-
if args.action
|
|
717
|
+
if args.action in {"complete", "clean-scratch"}:
|
|
625
718
|
result["receipt"] = str(Path(args.receipt).absolute())
|
|
626
|
-
result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()),
|
|
719
|
+
result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action, "--receipt", result["receipt"], *(["--writers-stopped"] if args.writers_stopped else [])])
|
|
627
720
|
print(json.dumps(result, sort_keys=True))
|
|
628
721
|
return 1
|
|
629
722
|
|
|
@@ -770,11 +863,138 @@ class CompletionTests(unittest.TestCase):
|
|
|
770
863
|
self.task = Path(result["worktree"])
|
|
771
864
|
return result
|
|
772
865
|
|
|
773
|
-
|
|
866
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
867
|
+
def test_disposable_scratch_cleans_for_local_only_and_preserves_source(self):
|
|
868
|
+
result = self.allocate()
|
|
869
|
+
scratch = Path(result["scratch"])
|
|
870
|
+
(scratch / "target").mkdir()
|
|
871
|
+
(scratch / "target" / "object").write_bytes(b"rebuildable")
|
|
872
|
+
(self.task / "uncommitted-work").write_text("preserve")
|
|
873
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--local-only", "--writers-stopped")
|
|
874
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
875
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
876
|
+
self.assertEqual((self.task / "uncommitted-work").read_text(), "preserve")
|
|
877
|
+
self.assertEqual(result["scratch_cleanup"]["logical_bytes_removed"], 11)
|
|
878
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
879
|
+
self.assertEqual(result["status"], "SCRATCH_CLEAN")
|
|
880
|
+
|
|
881
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
882
|
+
def test_scratch_rejects_active_writer_and_requires_owner_assertion(self):
|
|
883
|
+
scratch = Path(self.allocate()["scratch"])
|
|
884
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, success=False)
|
|
885
|
+
self.assertIn("--writers-stopped", result["reason"])
|
|
886
|
+
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=scratch)
|
|
887
|
+
try:
|
|
888
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
889
|
+
self.assertIn("active process", result["reason"])
|
|
890
|
+
self.assertTrue(scratch.is_dir())
|
|
891
|
+
finally:
|
|
892
|
+
child.terminate()
|
|
893
|
+
child.wait(timeout=5)
|
|
894
|
+
self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
895
|
+
|
|
896
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
897
|
+
def test_scratch_rejects_redirect_and_git_data_but_does_not_follow_child_links(self):
|
|
898
|
+
scratch = Path(self.allocate()["scratch"])
|
|
899
|
+
outside = self.root / "outside"
|
|
900
|
+
outside.mkdir()
|
|
901
|
+
(outside / "keep").write_text("source")
|
|
902
|
+
retained = scratch.with_name("retained")
|
|
903
|
+
scratch.rename(retained)
|
|
904
|
+
scratch.symlink_to(outside, target_is_directory=True)
|
|
905
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
906
|
+
self.assertIn("redirected", result["reason"])
|
|
907
|
+
scratch.unlink()
|
|
908
|
+
retained.rename(scratch)
|
|
909
|
+
(scratch / ".GiT").write_text("gitdir: elsewhere")
|
|
910
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
911
|
+
self.assertIn("Git recovery data", result["reason"])
|
|
912
|
+
(scratch / ".GiT").unlink()
|
|
913
|
+
(scratch / "external-link").symlink_to(outside, target_is_directory=True)
|
|
914
|
+
self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
915
|
+
self.assertEqual((outside / "keep").read_text(), "source")
|
|
916
|
+
|
|
917
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
918
|
+
def test_scratch_reuse_and_partial_cleanup_keep_original_identity(self):
|
|
919
|
+
scratch = Path(self.allocate()["scratch"])
|
|
920
|
+
(scratch / "first-build").write_bytes(b"first")
|
|
921
|
+
self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
922
|
+
(scratch / "resumed-build").write_bytes(b"next")
|
|
923
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
|
|
924
|
+
self.assertEqual(result["scratch_cleanup"]["logical_bytes_removed"], 4)
|
|
925
|
+
self.assertFalse(any(scratch.iterdir()))
|
|
926
|
+
scratch.rename(scratch.with_name("original"))
|
|
927
|
+
scratch.mkdir()
|
|
928
|
+
(scratch / "foreign").write_text("preserve")
|
|
929
|
+
result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
|
|
930
|
+
self.assertIn("replaced", result["reason"])
|
|
931
|
+
self.assertIn("--writers-stopped", result["resume"])
|
|
932
|
+
self.assertEqual((scratch / "foreign").read_text(), "preserve")
|
|
933
|
+
|
|
934
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
935
|
+
def test_scratch_refusal_does_not_block_local_only_delivery(self):
|
|
936
|
+
scratch = Path(self.allocate()["scratch"])
|
|
937
|
+
(scratch / ".git").mkdir()
|
|
938
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--local-only", "--writers-stopped")
|
|
939
|
+
self.assertEqual(result["status"], "LOCAL_ONLY")
|
|
940
|
+
self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
|
|
941
|
+
self.assertIn("Git recovery data", result["scratch_cleanup"]["reason"])
|
|
942
|
+
self.assertEqual(json.loads(self.receipt.read_text())["scratch_cleanup"]["status"], "RETAINED")
|
|
943
|
+
self.assertTrue((scratch / ".git").is_dir())
|
|
944
|
+
|
|
945
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
946
|
+
def test_scratch_refusal_reports_delivery_separately(self):
|
|
947
|
+
scratch = Path(self.allocate()["scratch"])
|
|
948
|
+
self.accept()
|
|
949
|
+
(scratch / ".git").mkdir()
|
|
950
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--acceptance", self.acceptance, "--writers-stopped")
|
|
951
|
+
self.assertEqual(result["status"], "CLEANUP_PENDING")
|
|
952
|
+
self.assertEqual(result["delivery_status"], "COMPLETE")
|
|
953
|
+
self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
|
|
954
|
+
self.assertIn("--writers-stopped", result["scratch_cleanup"]["resume"])
|
|
955
|
+
self.assertIn("--writers-stopped", result["resume"])
|
|
956
|
+
(scratch / ".git").rmdir()
|
|
957
|
+
result, _ = self.cli("complete", "--receipt", self.receipt, "--writers-stopped")
|
|
958
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
959
|
+
|
|
960
|
+
@unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
|
|
961
|
+
def test_scratch_mount_and_scan_failure_preserve_contents(self):
|
|
962
|
+
from unittest.mock import patch
|
|
963
|
+
scratch = Path(self.allocate()["scratch"])
|
|
964
|
+
(scratch / "keep").write_text("retained")
|
|
965
|
+
receipt = json.loads(self.receipt.read_text())
|
|
966
|
+
with patch.dict(clean_scratch.__globals__, {"scratch_mounts": lambda: [scratch / "same-device-bind"]}):
|
|
967
|
+
with self.assertRaisesRegex(CompletionError, "mounted filesystem"):
|
|
968
|
+
clean_scratch(receipt, self.receipt, True)
|
|
969
|
+
def failed_walk(*args, **kwargs):
|
|
970
|
+
kwargs["onerror"](PermissionError("unreadable subtree"))
|
|
971
|
+
with patch.object(os, "walk", failed_walk):
|
|
972
|
+
with self.assertRaisesRegex(PermissionError, "unreadable subtree"):
|
|
973
|
+
clean_scratch(receipt, self.receipt, True)
|
|
974
|
+
self.assertEqual((scratch / "keep").read_text(), "retained")
|
|
975
|
+
|
|
976
|
+
def test_scratch_mount_table_parsing_preserves_ambiguous_literal_paths(self):
|
|
977
|
+
from unittest.mock import patch
|
|
978
|
+
target = "/task/scratch/name on inside\\040literal"
|
|
979
|
+
with patch.object(sys, "platform", "darwin"), patch.dict(scratch_mounts.__globals__, {
|
|
980
|
+
"command": lambda args: "device on source on " + target + " (local, journaled)",
|
|
981
|
+
}):
|
|
982
|
+
self.assertIn(Path(target).resolve(), scratch_mounts())
|
|
983
|
+
alias = self.root / "scratch-alias"
|
|
984
|
+
alias.symlink_to(self.root, target_is_directory=True)
|
|
985
|
+
with patch.object(sys, "platform", "darwin"), patch.dict(scratch_mounts.__globals__, {
|
|
986
|
+
"command": lambda args: "device on " + str(alias / "mounted") + " (local)",
|
|
987
|
+
}):
|
|
988
|
+
self.assertEqual(scratch_mounts(), [(self.root / "mounted").resolve()])
|
|
989
|
+
with patch.object(sys, "platform", "linux"), patch.object(Path, "read_text", return_value="truncated row"):
|
|
990
|
+
with self.assertRaisesRegex(CompletionError, "cannot parse mount table"):
|
|
991
|
+
scratch_mounts()
|
|
992
|
+
|
|
993
|
+
def accept(self, pipeline=False, queue=False, spec_expected=None, spec_name="spec.md"):
|
|
774
994
|
if spec_expected is not None:
|
|
775
|
-
(self.task /
|
|
995
|
+
(self.task / spec_name).write_text("# Fixture\nProduct contains accepted bytes.\n", encoding="utf-8")
|
|
776
996
|
(self.task / "spec.expected.json").write_text(json.dumps(spec_expected), encoding="utf-8")
|
|
777
|
-
self.g("add",
|
|
997
|
+
self.g("add", spec_name, "spec.expected.json", work=self.task)
|
|
778
998
|
(self.task / "product").write_text("accepted\n", encoding="utf-8")
|
|
779
999
|
self.g("add", "product", work=self.task)
|
|
780
1000
|
self.g("commit", "-m", "scoped task", work=self.task)
|
|
@@ -802,7 +1022,7 @@ class CompletionTests(unittest.TestCase):
|
|
|
802
1022
|
self.state = {"run_id": a["run_id"], "mode": "free-form", "source": source, "phases": phases, "process_evidence": None}
|
|
803
1023
|
if spec_expected is not None:
|
|
804
1024
|
self.state["mode"] = "spec"
|
|
805
|
-
self.state["source"] = {"type": "spec", "spec_path":
|
|
1025
|
+
self.state["source"] = {"type": "spec", "spec_path": spec_name, "spec_sha256": hashlib.sha256((self.task / spec_name).read_bytes()).hexdigest()}
|
|
806
1026
|
(archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
|
|
807
1027
|
(archive / "finish-gate.summary.json").write_text(json.dumps({"mode": self.state["mode"], "exit": 0, "offenders": 0, "checked": 1}), encoding="utf-8")
|
|
808
1028
|
(archive / "verify-merge.summary.json").write_text(json.dumps({"verdict": "PASS"}), encoding="utf-8")
|
|
@@ -1167,6 +1387,22 @@ class CompletionTests(unittest.TestCase):
|
|
|
1167
1387
|
self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
|
|
1168
1388
|
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1169
1389
|
|
|
1390
|
+
def test_named_spec_custody(self):
|
|
1391
|
+
self.allocate(); self.accept(pipeline=True, spec_expected={"pure_design": True}, spec_name="X.md")
|
|
1392
|
+
result, _ = self.complete("--mode", "pr")
|
|
1393
|
+
self.assertEqual(result["status"], "PR")
|
|
1394
|
+
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
|
1395
|
+
self.assertIn("spec.expected.json", receipt["files"])
|
|
1396
|
+
self.assertEqual((self.receipt.parent / "custody/spec.expected.json").read_bytes(), (self.task / "spec.expected.json").read_bytes())
|
|
1397
|
+
|
|
1398
|
+
def test_named_spec_missing_required_process_evidence(self):
|
|
1399
|
+
expected = {"process_evidence": [{"id": "red-first", "phase": "implement", "argv": [sys.executable, "-c", "print('red')"], "exit_code": 0, "timeout_sec": 10}]}
|
|
1400
|
+
self.allocate(); self.accept(pipeline=True, spec_expected=expected, spec_name="X.md")
|
|
1401
|
+
_, r = self.complete(success=False)
|
|
1402
|
+
self.assertNotEqual(r.returncode, 0)
|
|
1403
|
+
self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
|
|
1404
|
+
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
|
|
1405
|
+
|
|
1170
1406
|
|
|
1171
1407
|
if __name__ == "__main__":
|
|
1172
1408
|
runpy.run_path(str(Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
@@ -96,7 +96,7 @@ python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --check-expected <expected-pat
|
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
Map mechanically decidable rules from both Requirements and Constraints into the existing carriers. Validate each new guard with a violating control that fails and an allowed control that passes, preserving permitted pre-existing occurrences:
|
|
99
|
-
- `verification_commands` ← `## Verification` + commands the conversation surfaced. For semantic annotation or exception-handling rules, use a suitable syntax-aware/executable check, preferring existing checks; explicitly retain uncovered semantics as source-review obligations.
|
|
99
|
+
- `verification_commands` ← `## Verification` + commands the conversation surfaced. While drafting both Verification and its carrier, omit a standalone check only when another command in that contract runs the same assertions under the required execution conditions and propagates failure; describe that shared coverage in Verification and keep both carriers aligned. Preserve distinct scenarios and required independent verification; execute approved commands unchanged without reusing results across phases. For semantic annotation or exception-handling rules, use a suitable syntax-aware/executable check, preferring existing checks; explicitly retain uncovered semantics as source-review obligations.
|
|
100
100
|
- `forbidden_patterns` ← literal syntax constraints only; regex does not prove semantics. It scans the whole file-filtered diff, including context, deletions and headers; additions-only guards must exclude those lines.
|
|
101
101
|
- `required_files` / `forbidden_files` ← file requirements and exclusions in Requirements, Constraints and Out of Scope.
|
|
102
102
|
- `max_deps_added` ← dependency limits in Requirements or Constraints (default 0 unless the spec licenses a new dep).
|
|
@@ -75,7 +75,7 @@ When the rules are silent (rare — pathological goal text), default to `medium`
|
|
|
75
75
|
The internal mini-spec written for trivial / medium / large-assumptions paths must satisfy:
|
|
76
76
|
|
|
77
77
|
- `## Requirements` non-empty, each bullet testable (CLI command, test command, observable file change).
|
|
78
|
-
- For generated verification
|
|
78
|
+
- For executable generated verification, put one fenced `json` object with only `verification_commands` inside the sentinel-marked `## Verification` section. Generated mode reads this inline carrier, not a sibling file. Follow `../../devlyn:ideate/references/spec-template.md` § "Sibling file: `spec.expected.json`" for constraint coverage, diff scope and violating/allowed controls, but express those checks as executable `verification_commands`; sibling-only fields such as `required_files` and `forbidden_patterns` are rejected inline. Preserve uncovered semantics as source-review obligations.
|
|
79
79
|
- `## Verification` is preceded by a `<!-- devlyn:verification -->` sentinel on its own line directly above the heading — the machine locator `spec-verify-check.py` uses; the heading text itself is decorative and may be any language. `## Verification` non-empty if the goal implies any runnable acceptance check. Empty Verification is allowed only when all Requirements are pure-design (e.g. "follow existing pattern X").
|
|
80
80
|
- If a free-form goal includes pair-evidence intent and already includes an actionable solo-headroom hypothesis, preserve that literal hypothesis in `.devlyn/criteria.generated.md` unchanged enough for VERIFY to detect `solo-headroom hypothesis`, `solo_claude`, `miss`, and the backticked observable command line that itself contains `miss`, emit the canonical `spec.solo_headroom_hypothesis` pair trigger reason, and satisfy regenerated-evidence checks such as `benchmark audit --require-hypothesis-trigger`.
|
|
81
81
|
- If a free-form goal includes unmeasured pair-candidate intent and already includes solo ceiling avoidance, preserve that literal note in `.devlyn/criteria.generated.md` unchanged enough for reviewers to see `solo ceiling avoidance`, `solo_claude`, and the concrete difference from rejected or solo-saturated controls such as `S2`-`S6`.
|
|
@@ -59,7 +59,7 @@ Append all findings; do not stop on the first failure.
|
|
|
59
59
|
|
|
60
60
|
<output>
|
|
61
61
|
- `.devlyn/build_gate.findings.jsonl` — JSONL stream, one finding per line. Schema: `{id, rule_id, severity, file, line, message, fix_hint, criterion_ref}`.
|
|
62
|
-
- `.devlyn/build_gate.log.md` — human-readable
|
|
62
|
+
- `.devlyn/build_gate.log.md` — human-readable gate results with raw output or links to complete raw-output files, using sealed artifacts where available. Retain complete inventories/hash maps in `.devlyn/` files; report their paths, digests and relevant deltas instead of dumping them into the conversation.
|
|
63
63
|
- Report `PASS` if zero CRITICAL/HIGH findings, `FAIL` for genuine product findings, or `BLOCKED:build-env-underprovisioned` only for the evidence-backed capability path above. A capability blocker leaves no product finding. Do not edit `pipeline.state.json` yourself — the orchestrator records it via `state-phase-write.py` from these artifacts.
|
|
64
64
|
</output>
|
|
65
65
|
|
|
@@ -10,11 +10,13 @@ Independent quality layer. You answer one question: did the diff deliver what th
|
|
|
10
10
|
- `spec.md` (or `.devlyn/criteria.generated.md` for free-form mode) — the contract.
|
|
11
11
|
- `spec.expected.json` — the mechanical acceptance contract per `_shared/expected.schema.json`.
|
|
12
12
|
- The cumulative diff against `state.base_ref.sha`.
|
|
13
|
-
- The source hash (`state.source.spec_sha256` for spec mode, `state.source.criteria_sha256` for generated free-form mode) — re-read the source contract from disk and confirm the hash matches; if it does not,
|
|
14
|
-
- `.devlyn/spec-verify.results.json` plus
|
|
13
|
+
- The source hash (`state.source.spec_sha256` for spec mode, `state.source.criteria_sha256` for generated free-form mode) — re-read the source contract from disk and confirm the hash matches; if it does not, emit a verdict-binding finding with reason `source_sha256_mismatch` and terminal verdict `BLOCKED`, then stop reviewing without editing state.
|
|
14
|
+
- `.devlyn/spec-verify.results.json` plus any required validated VERIFY
|
|
15
15
|
process-evidence manifest and raw stdout/stderr streams named by its
|
|
16
16
|
`process_evidence` carrier. These are immutable MECHANICAL results; rehash
|
|
17
|
-
every named byte before JUDGE spawn and again during merge.
|
|
17
|
+
every named byte before JUDGE spawn and again during merge. Valid pure-design
|
|
18
|
+
contracts with no executable obligations have `commands: []` and
|
|
19
|
+
`process_evidence: null`.
|
|
18
20
|
|
|
19
21
|
You do NOT receive: PLAN, IMPLEMENT's reasoning, BUILD_GATE's findings, CLEANUP's allowlist negotiations. Reading those would compromise independence. Inspect authorized source, diff and sealed evidence using native read/search tools or non-mutating shell commands; executable verification belongs exclusively to MECHANICAL.
|
|
20
22
|
</input>
|
|
@@ -40,14 +42,14 @@ On the constrained Windows read route, the orchestrator supplies the complete sp
|
|
|
40
42
|
|
|
41
43
|
Grade the diff against the spec on rubric axes:
|
|
42
44
|
|
|
43
|
-
- **Spec compliance** —
|
|
45
|
+
- **Spec compliance** — does cited evidence show how every applicable Requirement and Constraint is satisfied?
|
|
44
46
|
- **Scope** — does the diff touch only files PLAN listed (or the cleanup allowlist)? Out-of-scope file = HIGH finding `scope.out-of-scope-violation`.
|
|
45
47
|
- **Quality** — does the implementation follow the framework's idiomatic patterns, or are there hand-rolled helpers replacing standard primitives? `design.unidiomatic-pattern` MEDIUM if so.
|
|
46
48
|
- **Consistency** — internal style (naming, error shape, module boundaries) consistent with the surrounding code.
|
|
47
49
|
|
|
48
50
|
**Bounded primary review**: the primary JUDGE makes one broad pass over the
|
|
49
51
|
source contract, sealed MECHANICAL carrier, and cumulative diff, covering all
|
|
50
|
-
four rubric axes and every binding Requirement clause. It then makes one
|
|
52
|
+
four rubric axes and every binding Requirement and Constraint clause. It then makes one
|
|
51
53
|
targeted interaction pass over the clauses the broad pass left unresolved.
|
|
52
54
|
Before any third pass, emit the required terminal result. If R1–R8 or another
|
|
53
55
|
spec axis remains uncovered, emit a verdict-binding BLOCKED coverage finding
|
|
@@ -55,7 +57,7 @@ instead of continuing or assuming PASS.
|
|
|
55
57
|
|
|
56
58
|
For each finding, write file:line evidence. Do not paraphrase code; quote it.
|
|
57
59
|
|
|
58
|
-
**Clause-level check**: split each Requirement into its binding clauses before
|
|
60
|
+
**Clause-level check**: split each Requirement and Constraint into its binding clauses before
|
|
59
61
|
you pass it. Words like `before`, `after`, `once`, `always`, `never`,
|
|
60
62
|
`regardless`, `irrelevant`, `permanent`, `idempotent`, `duplicate`, `raw`, and
|
|
61
63
|
`signature` usually encode a separate invariant. A passing verification command
|
|
@@ -90,7 +92,7 @@ risk probes and present in the sealed MECHANICAL evidence. Missing coverage is
|
|
|
90
92
|
a verdict-binding finding; review the implementation's clause and code order
|
|
91
93
|
without inventing a replacement command.
|
|
92
94
|
|
|
93
|
-
**Coverage check**:
|
|
95
|
+
**Coverage check**: account for every applicable Requirement and Constraint using sealed MECHANICAL evidence for required executable checks and cited source/design evidence for pure-design clauses or explicitly retained source-review obligations. Inspect applicable unchanged code and documentation; absence of a code change alone is not missing coverage. Source review never substitutes for required executable coverage. For any unsupported clause, emit a verdict-binding coverage finding identifying the missing evidence. JUDGE does not run checks or edit state; the orchestrator records `state.verify.coverage_failed: true` from coverage findings before merge.
|
|
94
96
|
|
|
95
97
|
**Verdict-binding check**: a demonstrated violation of an applicable mandatory
|
|
96
98
|
task, public, or existing test contract is binding, including unmet new
|
|
@@ -67,7 +67,7 @@ Single authoritative verdict source for `/devlyn:resolve`. The orchestrator bran
|
|
|
67
67
|
- **implement_passed_sha** — captured at end of PHASE 2; null until then. Activates the post-implement invariant for SURFACE_CLOSE, CLEANUP, and VERIFY.
|
|
68
68
|
- **criteria** — generated from spec's `## Requirements` checklist (one per `- [ ]`). `status: pending → implemented` is the legal transition. `failed_by_finding_ids` populates when VERIFY surfaces a finding tied to a criterion.
|
|
69
69
|
- **`.devlyn/untracked.baseline` artifact** — written at PHASE 0 (`spec-verify-check.py --write-untracked-baseline`); BUILD_GATE flags created-during-run unauthorized untracked files against it (`.devlyn/` exempt; semantics in `phases/build-gate.md` step 4).
|
|
70
|
-
- **verify.coverage_failed** — outcome-dependent telemetry set when JUDGE
|
|
70
|
+
- **verify.coverage_failed** — outcome-dependent telemetry set by the orchestrator when JUDGE reports missing clause evidence. It no longer gates pair dispatch: after MECHANICAL passes, `pair.default` dispatches both judges concurrently whenever `pair_default_enabled != false` and the OTHER engine is available. An orchestrator without foreground parallel dispatch runs the same two required judges sequentially; a primary blocker never skips the pair. A verdict-binding MECHANICAL blocker still skips both. Legacy complexity values remain accepted only for archived compatibility.
|
|
71
71
|
- **verify.pair_trigger** — strict decision state: `{ "eligible": boolean, "reasons": string[], "skipped_reason": string|null }`. Schema v3.0 eligible state requires `pair.default` plus every applicable telemetry reason and `skipped_reason: null`; outcome-dependent reasons are appended after both judges return. Canonical reasons are `pair.default`, `mode.verify-only`, `mode.pair-verify`, `complexity.high`, `complexity.large`, `spec.complexity.high`, `spec.complexity.large`, `spec.solo_headroom_hypothesis`, `risk.high`, `risk_probes.enabled`, `risk_probes.present`, `coverage.failed`, `mechanical.warning`, and `judge.warning`. Ineligible new-run state has empty reasons and only `user_no_pair`, `mechanical_blocker`, `auto_pair_other_engine_unavailable`, or null; `primary_judge_blocker` remains parser-recognized only for archived v2.0 replay and retains its existing pre-known-reason rejection. Explicit `mode.pair-verify` cannot use the unavailable-engine skip. Missing, contradictory, incomplete, or unknown trigger state BLOCKs VERIFY.
|
|
72
72
|
|
|
73
73
|
## Per-phase shape
|
|
@@ -24,6 +24,33 @@ names look generated. An interrupted allocation stays blocked for inspection;
|
|
|
24
24
|
do not delete its receipt and enroll the resulting branch. Pre-existing tasks
|
|
25
25
|
remain owner-managed and cannot use this helper retroactively.
|
|
26
26
|
|
|
27
|
+
Allocation also returns a receipt-owned `scratch` directory. Put disposable
|
|
28
|
+
build intermediates there (for example, set
|
|
29
|
+
`CARGO_TARGET_DIR` to `<scratch>/target`). Keep source checkouts, Git data,
|
|
30
|
+
lockfiles, reports and restart evidence outside scratch. Do not create unowned
|
|
31
|
+
temporary build trees or preserve them by copying them into evidence custody.
|
|
32
|
+
Prefer `TemporaryDirectory`/`finally` for short-lived test fixtures.
|
|
33
|
+
|
|
34
|
+
After waiting for task writers, `complete --writers-stopped` empties this
|
|
35
|
+
scratch on delivery returns, including local-only and pending-PR routes. To
|
|
36
|
+
release build caches when parking or after a failed run, use:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
python3 "$DEVLYN_SHARED_DIR/task-complete.py" clean-scratch \
|
|
40
|
+
--receipt '<returned receipt.json>' --writers-stopped
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This empties only the prospectively owned scratch, preserves its directory
|
|
44
|
+
identity for interrupted cleanup and later builds, checks live users and reports
|
|
45
|
+
the logical byte count separately from actual free disk space. It does not depend on a product PASS or rewrite that verdict.
|
|
46
|
+
Unknown historical roots, source/recovery data, shared caches and terminal
|
|
47
|
+
history are never adopted or swept. A refused cleanup does not block source
|
|
48
|
+
delivery; it stays visible in the handoff with its receipt and retry command
|
|
49
|
+
(`CLEANUP_PENDING` after delivered source). No task is described as cleaned
|
|
50
|
+
while owned disposable files remain. On resumption, finish any pending scratch
|
|
51
|
+
cleanup before allocating more temporary storage. Rebuild from retained inputs
|
|
52
|
+
when needed; cleanup is not an instruction to restart parked work.
|
|
53
|
+
|
|
27
54
|
## Accept a scoped commit
|
|
28
55
|
|
|
29
56
|
Direct work: finish actual decisive checks and diff review, stage only the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
4
4
|
"description": "Loop & harness engineering for AI coding agents (Claude Code, Codex, and any AGENTS.md-driven agent) — take intent to shipped, engineer-quality software hands-free with phase-gated pipelines and agent orchestration",
|
|
5
5
|
"homepage": "https://github.com/fysoul17/devlyn-cli#readme",
|
|
6
6
|
"bin": {
|