devlyn-cli 3.1.2 → 3.1.3

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.
@@ -308,6 +308,36 @@ def bind_process_evidence(
308
308
  raise runner.EvidenceError(
309
309
  "spec-verify.results.json commands must be an array"
310
310
  )
311
+ preflight_failure = "preflight_failure" in results
312
+ if preflight_failure:
313
+ failure = results["preflight_failure"]
314
+ if (
315
+ not isinstance(failure, dict)
316
+ or set(failure) != {"run_id", "phase", "round", "findings"}
317
+ or failure["run_id"] != state.get("run_id")
318
+ or failure["phase"] != phase
319
+ or type(failure["round"]) is not int
320
+ or failure["round"] != runner.phase_round(state, phase)
321
+ ):
322
+ raise runner.EvidenceError("BUILD_GATE preflight failure identity is invalid")
323
+ record = failure["findings"]
324
+ if not isinstance(record, dict) or set(record) != {"path", "sha256"}:
325
+ raise runner.EvidenceError("BUILD_GATE preflight failure findings binding is invalid")
326
+ path = runner._checked_file(work, record["path"], "preflight failure findings")
327
+ raw = path.read_bytes()
328
+ if hashlib.sha256(raw).hexdigest() != record["sha256"]:
329
+ raise runner.EvidenceError("BUILD_GATE preflight failure findings digest mismatch")
330
+ findings = [loads_strict_json(line) for line in raw.decode("utf-8").splitlines() if line.strip()]
331
+ if len(findings) != 1 or not isinstance(findings[0], dict) or (
332
+ findings[0].get("phase") != phase
333
+ or findings[0].get("severity") != "CRITICAL"
334
+ or findings[0].get("rule_id") not in {
335
+ "correctness.spec-verify-malformed", "correctness.risk-probe-integrity",
336
+ }
337
+ ):
338
+ raise runner.EvidenceError("BUILD_GATE preflight failure requires its CRITICAL finding")
339
+ if verdict not in {"FAIL", "BLOCKED"}:
340
+ raise runner.EvidenceError("BUILD_GATE preflight failure requires FAIL or BLOCKED verdict")
311
341
  carrier = results.get("process_evidence")
312
342
  if carrier is None:
313
343
  if commands:
@@ -315,43 +345,42 @@ def bind_process_evidence(
315
345
  "BUILD_GATE commands exist without a process-evidence carrier"
316
346
  )
317
347
  manifest_path = work / runner.manifest_relative_path(state, phase)
318
- if manifest_path.exists():
348
+ if os.path.lexists(manifest_path):
319
349
  raise runner.EvidenceError(
320
350
  "BUILD_GATE manifest exists without a process-evidence carrier"
321
351
  )
322
- if runner.mechanical_evidence_required(work, state):
352
+ if not preflight_failure and runner.mechanical_evidence_required(work, state):
323
353
  raise runner.EvidenceError(
324
354
  "required BUILD_GATE evidence has no process-evidence carrier"
325
355
  )
326
- state.setdefault("process_evidence", None)
327
- return
328
- round_ = runner.phase_round(state, phase)
329
- manifest = carrier.get("manifest") if isinstance(carrier, dict) else None
330
- if (
331
- not isinstance(carrier, dict)
332
- or carrier.get("phase") != phase
333
- or carrier.get("round") != round_
334
- or not isinstance(manifest, dict)
335
- or manifest.get("path") != runner.manifest_relative_path(state, phase)
336
- ):
337
- raise runner.EvidenceError(
338
- "BUILD_GATE process-evidence carrier does not match the active run/round"
339
- )
340
- outcome = runner.validate_summary_commands(work, commands, carrier)
341
- if outcome["verdict"] == "BLOCKED" and verdict != "BLOCKED":
342
- denial = outcome["capability_denials"][0]
343
- raise runner.EvidenceError(
344
- "BUILD_GATE capability denial requires BLOCKED verdict: "
345
- f"{denial['id']}:{denial['operation']}"
346
- )
347
- if (
348
- outcome["verdict"] == "NEEDS_WORK"
349
- and verdict in {"PASS", "PASS_WITH_ISSUES"}
350
- ):
351
- raise runner.EvidenceError(
352
- "BUILD_GATE process evidence mismatch cannot complete as "
353
- f"{verdict}: {','.join(outcome['failed_ids'])}"
354
- )
356
+ else:
357
+ round_ = runner.phase_round(state, phase)
358
+ manifest = carrier.get("manifest") if isinstance(carrier, dict) else None
359
+ if (
360
+ not isinstance(carrier, dict)
361
+ or carrier.get("phase") != phase
362
+ or carrier.get("round") != round_
363
+ or not isinstance(manifest, dict)
364
+ or manifest.get("path") != runner.manifest_relative_path(state, phase)
365
+ ):
366
+ raise runner.EvidenceError(
367
+ "BUILD_GATE process-evidence carrier does not match the active run/round"
368
+ )
369
+ outcome = runner.validate_summary_commands(work, commands, carrier)
370
+ if outcome["verdict"] == "BLOCKED" and verdict != "BLOCKED":
371
+ denial = outcome["capability_denials"][0]
372
+ raise runner.EvidenceError(
373
+ "BUILD_GATE capability denial requires BLOCKED verdict: "
374
+ f"{denial['id']}:{denial['operation']}"
375
+ )
376
+ if (
377
+ outcome["verdict"] == "NEEDS_WORK"
378
+ and verdict in {"PASS", "PASS_WITH_ISSUES"}
379
+ ):
380
+ raise runner.EvidenceError(
381
+ "BUILD_GATE process evidence mismatch cannot complete as "
382
+ f"{verdict}: {','.join(outcome['failed_ids'])}"
383
+ )
355
384
  except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
356
385
  raise SystemExit(f"BLOCKED:process-evidence-invalid: {exc}") from exc
357
386
  existing = state.get("process_evidence")
@@ -362,8 +391,11 @@ def bind_process_evidence(
362
391
  try:
363
392
  for prior in existing:
364
393
  runner.validate_bound_carrier(work, prior)
365
- except runner.EvidenceError as exc:
394
+ except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
366
395
  raise SystemExit(f"BLOCKED:process-evidence-invalid: {exc}") from exc
396
+ if carrier is None:
397
+ state.setdefault("process_evidence", None)
398
+ return
367
399
  if any(
368
400
  isinstance(item, dict)
369
401
  and item.get("phase") == carrier["phase"]
@@ -2996,6 +3028,144 @@ def self_test() -> int:
2996
3028
 
2997
3029
  test_interrupted_build_gate()
2998
3030
 
3031
+ def test_preflight_build_gate() -> None:
3032
+ work = (devlyn / "preflight-build-gate").resolve()
3033
+ active = work / ".devlyn"
3034
+ active.mkdir(parents=True)
3035
+ state_file = active / "pipeline.state.json"
3036
+ results_file = active / "spec-verify.results.json"
3037
+ checker = pathlib.Path(__file__).with_name("spec-verify-check.py")
3038
+ env = {key: value for key, value in os.environ.items() if key not in {
3039
+ "BENCH_WORKDIR", "SPEC_VERIFY_PHASE", "SPEC_VERIFY_FINDINGS_FILE",
3040
+ "SPEC_VERIFY_FINDING_PREFIX",
3041
+ } and not key.startswith("DEVLYN_INVOCATION_")}
3042
+ runner = process_evidence_module()
3043
+
3044
+ def complete(verdict: str) -> subprocess.CompletedProcess[str]:
3045
+ return subprocess.run(
3046
+ [sys.executable, str(pathlib.Path(__file__).resolve()),
3047
+ "--devlyn-dir", ".devlyn", "--phase", "build_gate", "complete",
3048
+ "--verdict", verdict, "--findings-file", ".devlyn/spec-verify-findings.jsonl"],
3049
+ cwd=work, env=env, capture_output=True, text=True, encoding="utf-8",
3050
+ )
3051
+
3052
+ history = []
3053
+ for round_, scenario in enumerate(("fresh", "language", "stale", "denied")):
3054
+ fixture = {
3055
+ "version": "3.0", "run_id": "rs-preflight-build-gate",
3056
+ "source": {"type": "generated", "criteria_path": ".devlyn/criteria.generated.md"},
3057
+ "phases": {"build_gate": None}, "process_evidence": copy.deepcopy(history) or None,
3058
+ }
3059
+ do_spawn(fixture, "build_gate", round_, None, None, "claude", None)
3060
+ write_state(state_file, fixture)
3061
+ original_state = state_file.read_bytes()
3062
+ results_file.unlink(missing_ok=True)
3063
+ relative = runner.manifest_relative_path(fixture, "build_gate")
3064
+ manifest = work / relative
3065
+ obligation = {"id": "type-check", "phase": "build_gate",
3066
+ "argv": [sys.executable, "-c", "raise SystemExit(7)"]}
3067
+ if scenario == "language":
3068
+ runner.capture_process(work, manifest, fixture["run_id"], "build_gate", round_, obligation)
3069
+ elif scenario == "denied":
3070
+ runner.record_capability_denial(
3071
+ work, manifest, fixture["run_id"], "build_gate", round_, obligation,
3072
+ "subprocess", b"route denied subprocess",
3073
+ )
3074
+ elif scenario == "stale":
3075
+ results_file.write_bytes(prior_results)
3076
+ observed = {path: path.read_bytes() for path in manifest.parent.iterdir()} if manifest.exists() else {}
3077
+ rejected = subprocess.run(
3078
+ [sys.executable, str(checker), "--include-risk-probes"],
3079
+ cwd=work, env=env, capture_output=True, text=True, encoding="utf-8",
3080
+ )
3081
+ assert rejected.returncode == 1 and "source.criteria_path" in rejected.stderr, rejected.stderr
3082
+ results = loads_strict_json(results_file.read_text(encoding="utf-8"))
3083
+ assert results["preflight_failure"]["round"] == round_
3084
+ prior_results = results_file.read_bytes()
3085
+ finding = active / "spec-verify-findings.jsonl"
3086
+ finding_bytes = finding.read_bytes()
3087
+ for verdict in ("PASS", "PASS_WITH_ISSUES", "NEEDS_WORK"):
3088
+ denied = complete(verdict)
3089
+ assert denied.returncode != 0 and "preflight failure requires" in denied.stderr, denied.stderr
3090
+ assert state_file.read_bytes() == original_state
3091
+ if scenario == "denied":
3092
+ denied = complete("FAIL")
3093
+ assert denied.returncode != 0 and "capability denial requires BLOCKED" in denied.stderr
3094
+ assert state_file.read_bytes() == original_state
3095
+ completed = complete("BLOCKED" if scenario == "denied" else "FAIL")
3096
+ assert completed.returncode == 0, completed.stderr
3097
+ bound = read_state(state_file)
3098
+ assert bound["phases"]["build_gate"]["verdict"] == ("BLOCKED" if scenario == "denied" else "FAIL")
3099
+ assert finding.read_bytes() == finding_bytes and results_file.read_bytes() == prior_results
3100
+ assert {path: path.read_bytes() for path in observed} == observed
3101
+ if observed:
3102
+ assert bound["process_evidence"] == [*history, results["process_evidence"]]
3103
+ else:
3104
+ assert bound["process_evidence"] == (history or None) and results["commands"] == []
3105
+ history = bound["process_evidence"] or []
3106
+ for mutation in ("run", "phase", "round", "bool-round", "digest", "escape", "finding", "null"):
3107
+ state_file.write_bytes(original_state)
3108
+ altered = copy.deepcopy(results)
3109
+ failure = altered["preflight_failure"]
3110
+ if mutation in {"run", "phase", "round", "bool-round"}:
3111
+ field, value = {"run": ("run_id", "rs-other"), "phase": ("phase", "verify"),
3112
+ "round": ("round", round_ + 1), "bool-round": ("round", True)}[mutation]
3113
+ failure[field] = value
3114
+ elif mutation == "null":
3115
+ altered["preflight_failure"] = None
3116
+ elif mutation == "finding":
3117
+ finding.write_bytes(b"{}\n")
3118
+ failure["findings"]["sha256"] = hashlib.sha256(finding.read_bytes()).hexdigest()
3119
+ else:
3120
+ failure["findings"]["path" if mutation == "escape" else "sha256"] = (
3121
+ "../outside.jsonl" if mutation == "escape" else "0" * 64
3122
+ )
3123
+ results_file.write_text(json.dumps(altered), encoding="utf-8")
3124
+ result = complete("FAIL")
3125
+ assert result.returncode != 0 and "BLOCKED:process-evidence-invalid" in result.stderr, (mutation, result.stderr)
3126
+ assert state_file.read_bytes() == original_state
3127
+ finding.write_bytes(finding_bytes)
3128
+ results_file.write_bytes(prior_results)
3129
+ if scenario in {"fresh", "stale"}:
3130
+ state_file.write_bytes(original_state)
3131
+ malformed_prior = read_state(state_file)
3132
+ malformed_prior["process_evidence"] = {}
3133
+ write_state(state_file, malformed_prior)
3134
+ before = state_file.read_bytes()
3135
+ invalid = complete("FAIL")
3136
+ assert invalid.returncode != 0 and "null or an array" in invalid.stderr, invalid.stderr
3137
+ assert state_file.read_bytes() == before
3138
+ state_file.write_bytes(original_state)
3139
+ if scenario == "stale":
3140
+ prior_stream = work / history[0]["streams"][0]["stdout"]["path"]
3141
+ saved = prior_stream.read_bytes()
3142
+ prior_stream.write_bytes(b"altered prior observation")
3143
+ invalid = complete("FAIL")
3144
+ assert invalid.returncode != 0 and "mismatch" in invalid.stderr, invalid.stderr
3145
+ assert state_file.read_bytes() == original_state
3146
+ prior_stream.write_bytes(saved)
3147
+ from unittest.mock import patch
3148
+ read_bytes = pathlib.Path.read_bytes
3149
+
3150
+ def unreadable_prior(path):
3151
+ if path == prior_stream:
3152
+ raise PermissionError("prior stream unreadable")
3153
+ return read_bytes(path)
3154
+
3155
+ fixture = read_state(state_file)
3156
+ unchanged = copy.deepcopy(fixture)
3157
+ with patch.object(pathlib.Path, "read_bytes", unreadable_prior):
3158
+ try:
3159
+ bind_process_evidence(fixture, "build_gate", "FAIL", active, work)
3160
+ except SystemExit as exc:
3161
+ assert str(exc) == "BLOCKED:process-evidence-invalid: prior stream unreadable", exc
3162
+ else:
3163
+ raise AssertionError("unreadable prior evidence completed")
3164
+ assert fixture == unchanged and state_file.read_bytes() == original_state
3165
+ print("PASS preflight rejection: actual checker-to-completion, success floors, identity/digest controls")
3166
+
3167
+ test_preflight_build_gate()
3168
+
2999
3169
  # complete() before spawn() must fail loudly, not silently invent data.
3000
3170
  write_state(state_path, {"phases": {}})
3001
3171
  state = read_state(state_path)
@@ -779,6 +779,14 @@ else:
779
779
  raise SystemExit('unexpected gh arguments: '+str(a))
780
780
  '''
781
781
 
782
+ GIT_DISPATCH = r'''#!/bin/sh
783
+ for arg do
784
+ case "$arg" in push|fetch|ls-remote|remove) exec python3 "$0.py" "$@";; esac
785
+ done
786
+ exec "$REAL_GIT" "$@"
787
+ '''
788
+
789
+
782
790
  GIT_WRAPPER = r'''#!/usr/bin/env python3
783
791
  import json, os, pathlib, subprocess, sys
784
792
  a = sys.argv[1:]
@@ -822,7 +830,7 @@ class CompletionTests(unittest.TestCase):
822
830
  for key in list(self.env):
823
831
  if key in {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"}:
824
832
  del self.env[key]
825
- for name, body in (("gh", FAKE_GH), ("git", GIT_WRAPPER)):
833
+ for name, body in (("gh", FAKE_GH), ("git.py", GIT_WRAPPER), ("git", GIT_DISPATCH)):
826
834
  path = self.bin / name
827
835
  path.write_text(body, encoding="utf-8")
828
836
  path.chmod(0o755)
@@ -110,7 +110,7 @@ Structural lint (inline check, no script needed):
110
110
  After lint passes:
111
111
  1. Write `<spec-dir>/<id>-<slug>/spec.md` (the spec).
112
112
  2. Generate `<spec-dir>/<id>-<slug>/spec.expected.json` following `references/spec-template.md` § "Sibling file: `spec.expected.json`" for constraint coverage, diff scope and guard controls.
113
- 3. Run `python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --check <spec-path>` to validate the verification carrier shape, supported `complexity` frontmatter, and any present actionable solo-headroom hypothesis; if the spec uses a legacy inline `## Verification` JSON carrier, any solo-headroom hypothesis command must match that carrier's `verification_commands[].cmd`. If exit 2, fix the carrier/frontmatter/hypothesis and re-run.
113
+ 3. Run `python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --check <spec-path>` to validate the actual spec's carrier shape and supported `complexity` frontmatter. Sibling `spec.expected.json` takes precedence over the legacy inline `## Verification` JSON carrier; any present actionable solo-headroom hypothesis command must match that carrier's `verification_commands[].cmd`. If exit 2, fix the file named in the error and re-run.
114
114
  4. Run `python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --check-expected <expected-path>` to validate sibling `spec.expected.json` against `_shared/expected.schema.json` plus sibling spec `complexity` frontmatter and any present actionable solo-headroom hypothesis; if the spec has a solo-headroom hypothesis, its observable command must match `spec.expected.json.verification_commands[].cmd`. If exit 2, fix the JSON/frontmatter/hypothesis and re-run.
115
115
  5. Print: `spec ready — /devlyn:resolve --spec <spec-path>`.
116
116
 
@@ -37,7 +37,7 @@ You must NOT:
37
37
  3. For each missing/malformed piece: apply the smallest allowed fix.
38
38
  4. Write the normalized spec. Default location: `<spec-dir>/<id>-<slug>/spec.md`. With `--in-place` flag: write to `<path>` directly (overwrites the original).
39
39
  5. Generate or fix `spec.expected.json` per the rules above. Same dir as the spec.
40
- 6. Run `python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --check <spec-path>` to validate the spec carrier and supported `complexity` frontmatter; if the spec uses a legacy inline `## Verification` JSON carrier, any solo-headroom hypothesis command must match that carrier's `verification_commands[].cmd`.
40
+ 6. Run `python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --check <spec-path>` to validate the actual spec's carrier shape and supported `complexity` frontmatter. Sibling `spec.expected.json` takes precedence over the legacy inline `## Verification` JSON carrier; any present actionable solo-headroom hypothesis command must match that carrier's `verification_commands[].cmd`. If exit 2, fix the file named in the error and re-run.
41
41
  7. Run `python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --check-expected <expected-path>` to validate sibling `spec.expected.json` plus sibling spec `complexity` frontmatter and any present solo-headroom hypothesis command against `spec.expected.json.verification_commands[].cmd`.
42
42
  8. If lint still fails after allowed fixes (e.g. Requirements section is empty in the source), surface the issue and exit non-zero — do NOT invent Requirements.
43
43
  9. If the preserved Requirements combine state mutation with ordering/priority,
@@ -95,10 +95,10 @@ Schema: `_shared/expected.schema.json`. Required when Requirements have observab
95
95
  python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --check-expected <expected-path>
96
96
  ```
97
97
 
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:
98
+ Map mechanically decidable rules from both Requirements and Constraints into the existing carriers. Preserve specific file constraints even when narrower than PLAN's scope. During BUILD_GATE, with `state.base_ref.sha` recorded, the existing `authorized_surface` check enforces PLAN's scope over the run diff and newly created untracked paths (`.devlyn/` exempt). Do not duplicate this general scope check with a whole-checkout scan in `verification_commands`. Validate each new guard with a violating control that fails and an allowed control that passes, preserving permitted pre-existing occurrences:
99
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
- - `required_files` / `forbidden_files` ← file requirements and exclusions in Requirements, Constraints and Out of Scope.
101
+ - `required_files` / `forbidden_files` ← literal file paths from Requirements, Constraints and Out of Scope; `forbidden_files` matches exact Git-relative paths (forward slashes, no leading `./`), including both names of a rename. Globs and directory-wide exclusions need a suitable `verification_commands` guard, not wildcard-looking entries in these lists.
102
102
  - `max_deps_added` ← dependency limits in Requirements or Constraints (default 0 unless the spec licenses a new dep).
103
103
  - `required_risk_probe_requirements` ← for specs where a Requirement combines ordering/priority, rollback, idempotency, auth/signature, concurrency, atomic-batch, or exact output shape, declare the risk categories PHASE 1.5 risk-probe derivation must cover directly, e.g. `[{"tag": "rollback_state", "derived_from": "<exact ## Verification bullet>"}]`. This replaces guessing the required categories from prose — declare them explicitly so the check works in any language. `tag` must be one of the risk-probe tags in `_shared/spec-verify-check.py:RISK_PROBE_TAGS`; `derived_from` must be an exact substring of `## Verification`. Omit entirely when the spec has no ordering/rollback/idempotency/auth/concurrency/atomic-batch/exact-shape risk.
104
104
 
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: devlyn:resolve
3
- description: Hands-free full pipeline for explicit resolve requests, formal specs, queue drains, material ambiguity, subsystem/design work, security/auth/payment/persistence/concurrency/public-API-contract changes, or no decisive acceptance check. Free-form goal or formal spec input. Plan → Implement → Build-gate → Cleanup → Verify (fresh subagent, findings-only). Mechanical-first verification; Verify dual-judge is default-when-available. Clear, local, reversible, low-risk conversational edits with decisive checks default to direct execution; explicit small resolve keeps the full workflow.
3
+ description: Default to direct work after inspecting scope and verification, including bounded multi-file changes. Use full resolve for explicit resolve/spec-mode requests, queue drains, or concrete interacting requirements or verification too complex for the current context. Domain labels, file count, spec documents and quoted skill paths alone do not trigger it. Investigate or clarify missing intent/access first. Explicit small resolve retains all phases and independent verification. Verify dual-judge is default-when-available.
4
4
  ---
5
5
 
6
- Orchestrator for the 2-skill harness pipeline. One fresh worker per phase; file-based handoff via `.devlyn/pipeline.state.json`. VERIFY spawns a fresh-context worker so independence is structural — not advisory.
6
+ The current CLI owns PHASE 0, state transitions, Git checkpoints and final report/archive. Spawn the canonical phase workers from this process; never delegate the whole run to another parent orchestrator. VERIFY uses a fresh, findings-only worker.
7
7
 
8
8
  <pipeline_config>
9
9
  $ARGUMENTS
@@ -297,7 +297,7 @@ the BUILD_GATE process-evidence manifest and complete the phase as
297
297
  `BLOCKED:build-env-underprovisioned`, not as a product finding or substitute
298
298
  command. The worker:
299
299
  1. Detects language/framework via project files (`package.json`, `pyproject.toml`, etc.).
300
- 2. Runs language-specific gates (tsc / lint / test).
300
+ 2. Runs language-specific gates (tsc / lint / test), deferring exact literal overlaps under `references/phases/build-gate.md`'s current-contract/current-round rule; task-context prompts must preserve that rule.
301
301
  3. Always runs `python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --include-risk-probes` (verification_commands literal-match plus `.devlyn/risk-probes.jsonl` when present). If `state.risk_profile.risk_probes_enabled == true`, the script requires `.devlyn/risk-probes.jsonl`; a missing file is a CRITICAL mechanical blocker, not a silent solo run. The script routes each command through `process-evidence.py`; preserve `.devlyn/spec-verify.results.json` and its validated BUILD_GATE carrier for the state-bound archive flow.
302
302
  4. If diff touches web-surface files: run the browser tier with the repo's available toolchain (for example Playwright or curl).
303
303
  5. Emits `.devlyn/build_gate.findings.jsonl` + `.devlyn/build_gate.log.md`.
@@ -306,7 +306,7 @@ State write: `phases.build_gate.{started_at, verdict, completed_at, duration_ms,
306
306
 
307
307
  Branch:
308
308
  - `PASS` → PHASE 4.
309
- - `FAIL` → fix loop. Spawn IMPLEMENT-engine agent with the build_gate findings as input, then run the common post-fix checkpoint with origin `build_gate`. On second FAIL with `state.rounds.global >= state.rounds.max_rounds` → halt with verdict `BLOCKED:build-gate-exhausted`.
309
+ - `FAIL` → before product repair, halt with report-level `BLOCKED:required-tools-unavailable` only if a parent probe confirms the failed invocation's external gate tool is absent, unchanged `state.base_ref.sha` configuration requires it, and cited explicit task/spec constraints prohibit supplying it through any authorized route. Derive the tool from that configuration and invocation, never diagnostic text; bind the probe's interpreter, work root and normalized child environment to the runner call sites and actual gate invocation, not parent PATH or a manifest command string alone. An available authorized tool route, product-module/configuration failure, changed tool configuration, permitted or unspecified supply, or uncertain probe/identity/environment retains the fix loop. A confirmed prohibited-tool blocker wins even with other product findings: leave them unrepaired and preserve phase `FAIL`, findings, raw streams and sealed manifests unchanged. Record the probe and constraint citation separately in parent halt evidence; no repair means no round increment. Otherwise spawn IMPLEMENT-engine agent with the build_gate findings as input, then run the common post-fix checkpoint with origin `build_gate`. On second FAIL with `state.rounds.global >= state.rounds.max_rounds` → halt with verdict `BLOCKED:build-gate-exhausted`.
310
310
  - `BLOCKED:build-env-underprovisioned` → halt immediately with the denied operation, original command, and process-evidence manifest/id; do not enter the product fix loop.
311
311
 
312
312
  ## PHASE 4: CLEANUP
@@ -75,8 +75,8 @@ 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 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
- - `## 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").
78
+ - For executable generated verification, put one fenced `json` object with `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
+ - `## 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. When all Requirements are pure-design with no runnable acceptance check, put `{"pure_design": true, "verification_commands": []}` in the fenced `json` block and retain semantic source-review obligations. This explicit declaration is required; a missing block, an unmarked empty list, or `pure_design: true` with commands is invalid.
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`.
82
82
  - Free-form mode mini-specs are written to `.devlyn/criteria.generated.md` (not to a roadmap path) — this is run-scoped artifact, not a documented spec.
@@ -3,7 +3,7 @@
3
3
  Per-engine adapter header is prepended at runtime. BUILD_GATE is mechanical / deterministic — same commands CI / Docker / production run.
4
4
 
5
5
  <role>
6
- Run language-specific gates and the spec literal-match verification. Emit findings; the orchestrator's fix loop consumes them.
6
+ Run language-specific gates and the spec literal-match verification. Emit findings; the orchestrator's fix loop consumes them. Git staging and commits belong to the parent IMPLEMENT checkpoint; verify any staging prerequisite with read-only Git inspection, never repeat `git add` or forward task staging instructions as worker actions.
7
7
  </role>
8
8
 
9
9
  <capability_contract>
@@ -46,7 +46,7 @@ Detect the project shape from files in `state.base_ref.sha`:
46
46
  </detection>
47
47
 
48
48
  <gates>
49
- Run in this order; each emits findings into `.devlyn/build_gate.findings.jsonl`:
49
+ Resolve the concrete commands for steps 1–3; detection examples are not command identities. Run them in order unless step 4's current valid literal contract, using its source precedence and checker defaults, schedules the exact command with the same source/test inputs, working directory and effective environment. Establish the work root and normalized child environment from the checker/runner call sites and the current invocation; a manifest's command string alone does not establish them. The literal must expect exit 0, cover every gate assertion and use the same effective timeout as the planned gate invocation; uncertain matches run normally. Defer a matching gate to step 4 and derive its usual per-error findings and severity from that invocation's validated current BUILD_GATE run/round raw streams, citing the evidence id. A completed nonzero exit still supplies the gate's usual failure findings. If step 4 leaves no validated exit-kind result for a deferred gate, run that gate normally and append its findings, preserving step 4's failures; capability denials retain the stop contract above. Keep every literal invocation and independent post-CLEANUP VERIFY. Each gate emits findings into `.devlyn/build_gate.findings.jsonl`:
50
50
 
51
51
  1. **Type check** (TypeScript / mypy / etc.). Each error → one finding, severity `HIGH`, rule `correctness.type-check`.
52
52
  2. **Lint**. Run a recognized configured language linter (for example, ESLint with a config, `ruff`, or `clippy`) and/or the exact `package.json` `scripts.lint` command. A declared specialized `lint:*` script (for example, `lint:json`) is not the general language lint gate on the strength of its declaration alone. If the spec's verification commands explicitly name such a specialized command, step 4 executes it and preserves its real failure severity. If no linter is applicable, log an explicit SKIP in `.devlyn/build_gate.log.md` and emit zero lint findings. Each error → finding, severity `MEDIUM`, rule `quality.lint`. Warnings stay LOW unless the spec elevates them.
@@ -59,6 +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
+ - A completed checker preflight rejection writes `spec-verify.results.json` with `preflight_failure` and the original CRITICAL finding's path/digest. Retain that source (default `.devlyn/spec-verify-findings.jsonl`) and append its finding to `.devlyn/build_gate.findings.jsonl`. Earlier gate observations remain bound, but no spec command is claimed to have run. An absent results file still follows the interrupted-observation `BLOCKED` path.
62
63
  - `.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
64
  - 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
65
  </output>
@@ -54,7 +54,7 @@ Single authoritative verdict source for `/devlyn:resolve`. The orchestrator bran
54
54
  - **engine** — any engine name with a shipped adapter (`_shared/adapters/<name>.md`); `"claude"` and `"codex"` today. A required unavailable engine stops the run with `BLOCKED:<engine>-unavailable`.
55
55
  - **engine_source** — `"flag" | "engines.json" | "default"` — provenance of the resolved executor engine (`_shared/engine-preflight.md#role-resolution`). Optional on archived pre-iter-0038 runs; absent means `default`.
56
56
  - **source** — downstream contract provenance. Spec/verify-only set `type: "spec"`, `spec_path`, and `spec_sha256`. Free-form sets `type: "generated"`, `goal_path: ".devlyn/goal.raw.txt"` + exact-byte `goal_sha256`, and `criteria_path: ".devlyn/criteria.generated.md"` + raw-byte `criteria_sha256`. VERIFY re-checks its contract hash.
57
- - **`.devlyn/external-diff.patch` artifact** — verify-only input; the verifier consumes it only when `mode` is `"verify-only"` and fails closed if it exists in any other mode.
57
+ - **`.devlyn/external-diff.patch` artifact** — verify-only Git patch with `a/` and `b/` path prefixes (`git diff --binary --src-prefix=a/ --dst-prefix=b/`). A zero-byte file means no changes; file-path checks reject malformed or other-prefix input. File exclusions cover both names in rename/copy headers. The verifier fails closed if the artifact exists outside `"verify-only"` mode.
58
58
  - **risk_profile** — PHASE 0 routing state. `high_risk` controls automatic risk probes; `risk_probes_enabled` / `risk_probes_explicit` preserve their current semantics; `pair_default_enabled` is false only for explicit `--no-pair`. `risk_profile` must remain an object with boolean `high_risk`, `risk_probes_enabled`, `risk_probes_explicit`, and `pair_default_enabled` fields when present, plus `reasons` as a string list. Malformed state blocks VERIFY because it can hide required routes.
59
59
  - **risk_probes_digest** — top-level sha256 written by PHASE 1.5 after probe validation; BUILD_GATE/VERIFY replay semantics live in `phases/build-gate.md` step 4.
60
60
  - **process_evidence** — absent or JSON null for legacy runs and new runs with no captured obligation; otherwise an append-only array of `{phase, round, manifest: {path, sha256}, streams: [{id, stdout: {path, sha256}, stderr: {path, sha256}}]}` carriers. Paths are worktree-relative and live under `.devlyn/process-evidence/<run_id>/<phase>/round-<round>/`. A successful IMPLEMENT completion/transition validates every sibling `spec.expected.json.process_evidence[]` item declared for `phase: "implement"`, rehashes both raw streams (including zero-byte files), and appends the carrier before accepting the checkpoint. Missing, altered, escaping, duplicate-id, or expectation-mismatched evidence blocks without a state mutation. Later phases reuse the same carrier shape; no schema-major bump is required because readers must accept absent/null legacy state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "3.1.2",
3
+ "version": "3.1.3",
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": {
@@ -356,7 +356,7 @@ PY
356
356
 
357
357
  incomplete="$tmp_install_marker/incomplete"
358
358
  mkdir -p "$incomplete/package" "$incomplete/home/.codex/skills" "$incomplete/claude/.claude/skills" "$incomplete/codex"
359
- cp -R bin config package.json "$incomplete/package/"
359
+ cp -R bin config package.json AGENTS.md CLAUDE.md "$incomplete/package/"
360
360
  rm -rf "$incomplete/package/config/skills/devlyn:queue"
361
361
  printf '{"version":"stale"}\n' > "$incomplete/home/.codex/skills/.devlyn-install.json"
362
362
  printf '{"version":"stale"}\n' > "$incomplete/claude/.claude/skills/.devlyn-install.json"
@@ -1298,7 +1298,7 @@ done
1298
1298
  for pattern in \
1299
1299
  'def stage_from_expected' \
1300
1300
  'stage_from_expected(' \
1301
- 'expected_found, expected_staged, expected_error, expected_path' \
1301
+ 'contract_found, _staged, expected_error, expected_path' \
1302
1302
  'def expected_contract_findings' \
1303
1303
  'correctness.forbidden-pattern' \
1304
1304
  'scope.max-deps-added-exceeded' \