devlyn-cli 3.1.2 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -6
- package/CLAUDE.md +7 -7
- package/README.md +34 -13
- package/bin/devlyn.js +44 -65
- package/bin/instruction-templates.json +82 -0
- package/bin/instructions.js +187 -0
- package/config/skills/_shared/adapters/claude.md +1 -5
- package/config/skills/_shared/adapters/codex.md +1 -5
- package/config/skills/_shared/adapters/omp.md +0 -4
- package/config/skills/_shared/archive_run.py +5 -1
- package/config/skills/_shared/codex-config.md +3 -14
- package/config/skills/_shared/engine-preflight.md +3 -3
- package/config/skills/_shared/expected.schema.json +2 -2
- package/config/skills/_shared/platform-support.py +4 -1
- package/config/skills/_shared/resolve-bootstrap.py +22 -2
- package/config/skills/_shared/role-config.py +14 -2
- package/config/skills/_shared/spec-verify-check.py +427 -101
- package/config/skills/_shared/state-phase-write.py +497 -49
- package/config/skills/_shared/task-complete.py +46 -5
- package/config/skills/_shared/terminal-claim-check.py +2 -2
- package/config/skills/devlyn:ideate/SKILL.md +1 -1
- package/config/skills/devlyn:ideate/references/from-spec-mode.md +1 -1
- package/config/skills/devlyn:ideate/references/spec-template.md +2 -2
- package/config/skills/devlyn:resolve/SKILL.md +29 -51
- package/config/skills/devlyn:resolve/references/free-form-mode.md +2 -2
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +8 -7
- package/config/skills/devlyn:resolve/references/phases/cleanup.md +5 -11
- package/config/skills/devlyn:resolve/references/phases/implement.md +1 -0
- package/config/skills/devlyn:resolve/references/phases/plan.md +3 -3
- package/config/skills/devlyn:resolve/references/state-schema.md +10 -9
- package/package.json +2 -1
- package/scripts/lint-skills.sh +14 -4
- package/scripts/test-owner-phases.py +193 -0
|
@@ -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
|
|
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.
|
|
@@ -92,13 +92,14 @@ Each entry under `phases.<name>` (for `plan`, `probe_derive`, `implement`, `surf
|
|
|
92
92
|
}
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
`history` is absent until a phase is re-entered. Immediately before re-entry overwrites the live lifecycle, `state-phase-write.py` appends those prior values as one object to `history[]`, creating the append-only array on first re-entry. Codex worker history also retains its state-bound `invocation_receipt`; PLAN history retains the complete prompt/output receipt.
|
|
95
|
+
`history` is absent until a phase is re-entered. Immediately before re-entry overwrites the live lifecycle, `state-phase-write.py` appends those prior values as one object to `history[]`, creating the append-only array on first re-entry. Codex worker history also retains its state-bound `invocation_receipt`; PLAN history retains the complete prompt/output receipt. Owner phase history preserves output/checkpoint digests, its round and present execution kind/worker provenance; historical absence of the kind remains absent.
|
|
96
96
|
|
|
97
97
|
- `verdict` — `"PASS" | "PASS_WITH_ISSUES" | "FAIL" | "NEEDS_WORK" | "BLOCKED"`. PHASE 6 (FINAL_REPORT) writes its own verdict per the terminal-verdict precedence.
|
|
98
|
-
- `
|
|
98
|
+
- `execution_kind` — owner PLAN uses `"orchestrator_context"`; owner BUILD_GATE/CLEANUP use `"orchestrator_commands"`, null engine/model fields and no worker prompt, receipt or role identity. Worker claims and current-round worker artifacts are rejected. Already-open absent-kind spans retain historical receipt validation; unknown or null kinds fail closed.
|
|
99
|
+
- `model_requested` / `model_effective` — `model_requested` is supplied by the orchestrator through `--model`; schema-v3 completion cannot replace the spawn engine or requested model. Schema-v3 Codex IMPLEMENT and historical worker PLAN/CLEANUP/BUILD_GATE phases validate requested dispatch using the canonical `.devlyn/<phase>.invocation.<round>.json`; state rehashes the receipt, round-scoped prompt and worker session and cross-checks model, sandbox, prompt, run, phase, round and exit. A structured native model-reroute event or malformed JSONL blocks completion, even at exit0. The receipt proves argv delivery, so `model_effective` remains null; absence of a reroute is not positive model attestation. Arbitrary paths and plaintext `model:` headers are not evidence. Claude JSON output accepts a singleton `modelUsage` key or, for multiple entries, the sole key whose token counters exactly match top-level usage. Missing/malformed evidence and requested/effective mismatch fail closed: the phase verdict becomes `BLOCKED` and completion fails.
|
|
99
100
|
- `output_sha256` — on schema-v3 PLAN completion, SHA-256 of exact `.devlyn/plan.md` bytes. Only caller-verdict BLOCKED with a lexically absent, never-bound plan records null; receipt/session attestation still applies. This terminal null permits only FINAL_REPORT lifecycle operations while the path remains lexically absent. Bound bytes are rehashed on later spawn/completion/transition, including BLOCKED; legal PLAN re-entry replaces the digest only at the new PLAN completion. FINAL_REPORT completion binds exact `.devlyn/final-report.md` bytes here and its canonical path in `artifacts.log_file`.
|
|
100
|
-
- `invocation_receipt` — state-bound `{path, sha256, sandbox, sandbox_network_access, argv_sha256, exit_code}` for schema-v3 Codex PLAN/
|
|
101
|
-
- `triggered_by` — null on first run; one of `"build_gate" | "verify"` when the phase is a fix-loop respawn.
|
|
101
|
+
- `invocation_receipt` — state-bound `{path, sha256, sandbox, sandbox_network_access, argv_sha256, exit_code}` for schema-v3 Codex IMPLEMENT and historical worker PLAN/CLEANUP/BUILD_GATE. Receipt schema 2.0 makes `sandbox` exactly `workspace-write`; `sandbox_network_access` is exactly `true` for BUILD_GATE and `false` for the other mutation phases. A missing CI capability or wider phase authority blocks before launch. Pre-2.0 receipts are intentionally rejected rather than migrated because they did not attest this capability; do not rewrite in-flight evidence. Continue through the owning session or start in a distinct worktree; restarting via explicit manual archive requires the operator to establish that prior writers stopped, never automatic archival to defeat admission refusal. A completed final report does not prove all interactive writers exited. The receipt is retained in history and rehashed before archive.
|
|
102
|
+
- `triggered_by` — null on first run; one of `"build_gate" | "cleanup" | "verify"` when the phase is a fix-loop respawn.
|
|
102
103
|
- `pre_sha` — captured before SURFACE_CLOSE or CLEANUP. It bounds that phase's post-spawn diff and rollback.
|
|
103
104
|
- `post_sha` — captured when SURFACE_CLOSE or CLEANUP completes; finish-gate subtracts CLEANUP's `pre_sha..post_sha` window.
|
|
104
105
|
- `sub_verdicts` — only populated for VERIFY: `{ "mechanical": "PASS|FAIL", "judge": "PASS|...", "pair_judge": "PASS|..." | "TIMEOUT" | null }`. Values are normalized by `verify-merge-findings.py`; model prose verdicts cannot upgrade or downgrade the deterministic findings-derived verdict. `sub_verdicts.pair_judge` is `"TIMEOUT"` when a pair judge exceeded its wall budget and a valid `.devlyn/verify.pair.timeout.json` marker was read — semantics in `references/phases/verify.md` (pair budget section).
|
|
@@ -111,9 +112,9 @@ Each entry under `phases.<name>` (for `plan`, `probe_derive`, `implement`, `surf
|
|
|
111
112
|
|
|
112
113
|
Phase lifecycle (`started_at`/`completed_at`/`duration_ms`/`round`/`triggered_by`/`verdict`) is written by a deterministic script, never hand-edited JSON — a prior hand-edited fix-loop respawn left `started_at` stale, corrupting cross-phase ordering because `completed_at`/`round`/`triggered_by` advanced to the new round while `started_at` didn't. Phase workers report their verdict and artifact paths in their reply; they never edit `pipeline.state.json` themselves.
|
|
113
114
|
|
|
114
|
-
1. **Spawn**: `state-phase-write.py --devlyn-dir .devlyn --phase <name> spawn --round <N> [--triggered-by build_gate|verify] [--pre-sha <sha>] [--engine <e>] [--model <requested-model>] [--prompt-sha256 <hex>]`. Re-entry preserves lifecycle/receipts in `history[]`, resets owned fields, and leaves fields such as `phases.implement.exec`; VERIFY also clears prior-round files. Schema-v3 Codex IMPLEMENT
|
|
115
|
+
1. **Spawn**: `state-phase-write.py --devlyn-dir .devlyn --phase <name> spawn --round <N> [--triggered-by build_gate|cleanup|verify] [--pre-sha <sha>] [--engine <e>] [--model <requested-model>] [--prompt-sha256 <hex>]`. Re-entry preserves lifecycle/receipts in `history[]`, resets owned fields, and leaves fields such as `phases.implement.exec`; VERIFY also clears prior-round files. Owner PLAN/BUILD_GATE/CLEANUP spans omit engine/model/prompt arguments. Metadata-bearing PLAN/CLEANUP calls retain the legacy worker API; owner spans cannot be relabeled as workers. Successful owner CLEANUP requires pre/post SHA equality, unchanged HEAD/tracked source and no new untracked files versus the baseline; failure closure preserves the observed delta; code/doc cleanup belongs to the selected IMPLEMENT invocation. Schema-v3 Codex IMPLEMENT and historical worker CLEANUP requires the requested model and prompt digest; an inherited top-level Codex engine is persisted into the phase rather than becoming an unattested null route. SURFACE_CLOSE inherits native Claude model settings when `--model` is omitted and rejects explicit blank models; it additionally requires `--input-patch-sha256 <hex> --prompt-sha256 <hex> --untracked-before-json <array>`; use `surface-check --authorized-surface-json <array>`, `surface-adjudication-recover --authorized-surface-json <array>` only after its adjudication-malformed result, `surface-rollback` for other failures, or `surface-skip` only for automatic Claude unavailability.
|
|
115
116
|
2. **Transition** (normal direct handoff): `state-phase-write.py ... --phase <current> transition` takes the same completion/attestation arguments plus caller-specified `--next-phase`, `--next-round`, `--next-triggered-by`, `--next-engine`, `--next-model`, and next-phase metadata. It validates a fixed legal edge, applies complete plus spawn to a detached candidate, and replaces state once; either both lifecycle writes land or neither. A passing IMPLEMENT edge first validates and binds all declared process evidence through `_shared/process-evidence.py`. The verb returns JSON paths/digests/state facts only and never chooses a phase, engine, branch, prompt, or agent.
|
|
116
|
-
3. **Complete** (after the orchestrator reads the phase's reply and determines the verdict, when no next phase opens): `python3 "$DEVLYN_SHARED_DIR/state-phase-write.py" --devlyn-dir .devlyn --phase <name> complete --verdict <V> [--post-sha <sha>] [--findings-file <path>] [--log-file <path>] [--engine-session-log <path>]`. Pass only the phase/round canonical worker session. SURFACE_CLOSE requires `.devlyn/surface-close.output.json` even for inherited model selection (`model_requested=null`); native JSON supplies `model_effective`, and missing/malformed evidence or an explicit model mismatch blocks completion; schema-v3 Codex PLAN/
|
|
117
|
+
3. **Complete** (after the orchestrator reads the phase's reply and determines the verdict, when no next phase opens): `python3 "$DEVLYN_SHARED_DIR/state-phase-write.py" --devlyn-dir .devlyn --phase <name> complete --verdict <V> [--post-sha <sha>] [--findings-file <path>] [--log-file <path>] [--engine-session-log <path>]`. Pass only the phase/round canonical worker session. SURFACE_CLOSE requires `.devlyn/surface-close.output.json` even for inherited model selection (`model_requested=null`); native JSON supplies `model_effective`, and missing/malformed evidence or an explicit model mismatch blocks completion; schema-v3 Codex IMPLEMENT and historical worker PLAN/BUILD_GATE/CLEANUP pass the wrapper-captured `.devlyn/<phase>.worker-session.<round>.jsonl` and requires its sibling invocation receipt plus `.devlyn/<phase>.prompt.<round>`. Never scan an engine-global session directory or synthesize a model header. `duration_ms` is derived from the phase's own `started_at`. `--verdict` is required except VERIFY, whose verdict remains owned by `verify-merge-findings.py --write-state`.
|
|
117
118
|
4. **Before archive** (PHASE 6 step 5): write `.devlyn/final-report.md` with exactly one first-line `<!-- devlyn:final-report run_id=<current state.run_id> -->` and a non-whitespace body, then complete FINAL_REPORT with `--log-file .devlyn/final-report.md`. Missing, unsafe, empty or mismatched report input fails before completion; the marker proves run identity, not semantic completeness. This also applies to BLOCKED missing-PLAN-output closure. Archive rehashes a present FINAL_REPORT `output_sha256` binding before any move; missing/altered files or malformed/null bindings fail without moving artifacts. Historical states with that field absent retain existing archive behavior. Archive prune skips runs whose final_report verdict is null (treated as in-flight).
|
|
118
119
|
|
|
119
120
|
## Terminal verdict (PHASE 6)
|
|
@@ -130,7 +131,7 @@ Precedence:
|
|
|
130
131
|
|
|
131
132
|
Header: `run_id | engine | mode | complexity | verdict | wall_time_s`.
|
|
132
133
|
|
|
133
|
-
Per-phase summary table: `phase | verdict | duration_ms | round | triggered_by | findings_count`; include SURFACE_CLOSE when run or skipped.
|
|
134
|
+
Per-phase summary table: `phase | verdict | duration_ms | round | triggered_by | findings_count`; include SURFACE_CLOSE when run or skipped and identify new BUILD_GATE as orchestrator commands with no separate model.
|
|
134
135
|
|
|
135
136
|
Findings table: each emitted finding's `severity | rule_id | file:line | message | confidence`.
|
|
136
137
|
|
|
@@ -144,6 +145,6 @@ PHASE 6 step 4 moves `PER_RUN_PATTERNS` plus every state-bound process manifest/
|
|
|
144
145
|
|
|
145
146
|
PHASE0 `state-phase-write.py --freeze-roles --default-engine <orchestrator-default>` freezes `role_resolution` (legacy engine/source, three role entries, input paths/digests, snapshot digest). `role_config_input` holds only the validated per-run roles object and provenance; `role_no_pair` records that flag. No config reread after freezing. Missing legacy snapshots are compatible; present null/malformed snapshots fail. The helper does not launch models.
|
|
146
147
|
|
|
147
|
-
`state.engine` remains the legacy executor
|
|
148
|
+
`state.engine` remains the legacy executor; PLAN stays orchestrator-fixed, BUILD runs orchestrator commands, and probe routing is unchanged. IMPLEMENT (including code/doc cleanup) and historical worker CLEANUP enforce frozen worker engine/model. Owner CLEANUP does not claim that identity. VERIFY spawn explicitly records the primary in `phases.verify.engine`; merge, timeout and OTHER exclusion use it, falling back only when absent. Requested settings are distinct from native-observed fields. Explicit Codex worker effort binds `role_argv` against the existing invocation argv digest; effort remains dispatch evidence, not provider-internal attestation.
|
|
148
149
|
|
|
149
150
|
Successful explicit judges retain `<engine>-judge.r<round>.*` raw/prompt/argv/derived/evidence files; `verify.role_evidence` binds their hashes, observed model/nullable effort and named evidence basis. VERIFY history preserves those bindings. Merge alone binds successful evidence; archive rehashes every bound artifact. Canonical `<engine>-judge.stdout` is the current collector input and is never substituted for the immutable round raw. Failed raw remains diagnostic and cannot become a successful binding.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
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": {
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"scripts/lint-fixtures.sh",
|
|
77
77
|
"scripts/lint-shadow-fixtures.sh",
|
|
78
78
|
"scripts/lint-skills.sh",
|
|
79
|
+
"scripts/test-owner-phases.py",
|
|
79
80
|
"CLAUDE.md",
|
|
80
81
|
"AGENTS.md"
|
|
81
82
|
],
|
package/scripts/lint-skills.sh
CHANGED
|
@@ -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"
|
|
@@ -569,6 +569,13 @@ if ! grep -Fq 'SAFE_RUN_ID_RE' config/skills/_shared/archive_run.py \
|
|
|
569
569
|
bad "archive_run.py must safely archive pair/risk-probe evidence and reject unsafe run ids"
|
|
570
570
|
fi
|
|
571
571
|
|
|
572
|
+
section "Check 6c1: Owner phase lifecycle and scope boundaries"
|
|
573
|
+
if python3 scripts/test-owner-phases.py; then
|
|
574
|
+
ok "owner phase CLI and Git regression tests passed"
|
|
575
|
+
else
|
|
576
|
+
bad "owner phase CLI and Git regression tests failed"
|
|
577
|
+
fi
|
|
578
|
+
|
|
572
579
|
section "Check 6c2: SURFACE_CLOSE v6 dispatch is complete and mirrored"
|
|
573
580
|
surface_close_missing=0
|
|
574
581
|
if python3 config/skills/_shared/state-phase-write.py --self-test >/dev/null 2>&1; then
|
|
@@ -632,7 +639,7 @@ for tree in config/skills .agents/skills; do
|
|
|
632
639
|
|| ! grep -Fq -- '--tools "Read,Grep,Glob,Edit,Write" --dangerously-skip-permissions --output-format json --strict-mcp-config --mcp-config '\''{"mcpServers":{}}'\''' "$skill" \
|
|
633
640
|
|| ! grep -Fq '.devlyn/surface-close.output.json' "$skill" \
|
|
634
641
|
|| ! grep -Fq '**Common post-fix checkpoint (BUILD_GATE and VERIFY):**' "$skill" \
|
|
635
|
-
|| ! grep -Fq 'durability-enforce --round <n> --origin-phase <build_gate|verify>' "$skill" \
|
|
642
|
+
|| ! grep -Fq 'durability-enforce --round <n> --origin-phase <build_gate|cleanup|verify>' "$skill" \
|
|
636
643
|
|| ! grep -Fq '`surface-rollback`' "$skill" \
|
|
637
644
|
|| grep -Fq 'Supplied digests' "$phase" \
|
|
638
645
|
|| grep -Fq 'Hash both artifacts first' "$phase" \
|
|
@@ -1298,7 +1305,7 @@ done
|
|
|
1298
1305
|
for pattern in \
|
|
1299
1306
|
'def stage_from_expected' \
|
|
1300
1307
|
'stage_from_expected(' \
|
|
1301
|
-
'
|
|
1308
|
+
'contract_found, _staged, expected_error, expected_path' \
|
|
1302
1309
|
'def expected_contract_findings' \
|
|
1303
1310
|
'correctness.forbidden-pattern' \
|
|
1304
1311
|
'scope.max-deps-added-exceeded' \
|
|
@@ -1346,7 +1353,10 @@ for file in \
|
|
|
1346
1353
|
config/skills/devlyn:resolve/SKILL.md \
|
|
1347
1354
|
.agents/skills/devlyn:resolve/SKILL.md
|
|
1348
1355
|
do
|
|
1349
|
-
if ! grep -Fq '
|
|
1356
|
+
if ! grep -Fq 'orchestrator commands with no separate model' "$file" \
|
|
1357
|
+
|| ! grep -Fq 'execution_kind: "orchestrator_commands"' "$file" \
|
|
1358
|
+
|| grep -Fq 'resolved BUILD_GATE engine through the fresh-worker route' "$file" \
|
|
1359
|
+
|| grep -Fq 'Codex-routed IMPLEMENT, BUILD_GATE, or CLEANUP spawn' "$file" \
|
|
1350
1360
|
|| ! grep -Fq 'BLOCKED:build-env-underprovisioned' "$file" \
|
|
1351
1361
|
|| ! grep -Fq '.devlyn/<primary-engine>-judge.stdout' "$file" \
|
|
1352
1362
|
|| ! grep -Fq '.devlyn/<other-engine>-judge.stdout' "$file" \
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Exercise the candidate's owner phases through the real CLI and Git boundary."""
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import runpy
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
|
|
13
|
+
SHARED = Path(__file__).resolve().parents[1] / "config/skills/_shared"
|
|
14
|
+
ENV = {k: v for k, v in os.environ.items() if not k.startswith("DEVLYN_INVOCATION_")}
|
|
15
|
+
ENV["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OwnerPhases(unittest.TestCase):
|
|
19
|
+
def setUp(self):
|
|
20
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
21
|
+
self.addCleanup(self.temp.cleanup)
|
|
22
|
+
self.work = Path(self.temp.name).resolve()
|
|
23
|
+
self.devlyn = self.work / ".devlyn"
|
|
24
|
+
self.devlyn.mkdir()
|
|
25
|
+
self.state_path = self.devlyn / "pipeline.state.json"
|
|
26
|
+
(self.devlyn / "untracked.baseline").write_text("")
|
|
27
|
+
self.git("init", "-q")
|
|
28
|
+
self.git("config", "user.name", "Owner phase test")
|
|
29
|
+
self.git("config", "user.email", "owner@example.invalid")
|
|
30
|
+
(self.work / ".gitignore").write_text(".devlyn/\n")
|
|
31
|
+
(self.work / "source.txt").write_text("original\n")
|
|
32
|
+
self.git("add", ".")
|
|
33
|
+
self.git("commit", "-qm", "fixture")
|
|
34
|
+
self.head = self.git("rev-parse", "HEAD")
|
|
35
|
+
self.save({"version": "3.0", "run_id": "rs-owner-test", "engine": "codex",
|
|
36
|
+
"phases": {}})
|
|
37
|
+
|
|
38
|
+
def git(self, *args):
|
|
39
|
+
return subprocess.check_output(["git", *args], cwd=self.work, text=True).strip()
|
|
40
|
+
|
|
41
|
+
def state(self):
|
|
42
|
+
return json.loads(self.state_path.read_text())
|
|
43
|
+
|
|
44
|
+
def save(self, value):
|
|
45
|
+
self.state_path.write_text(json.dumps(value))
|
|
46
|
+
|
|
47
|
+
def cli(self, phase, *args, error=None):
|
|
48
|
+
before = self.state_path.read_bytes()
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
[sys.executable, str(SHARED / "state-phase-write.py"), "--devlyn-dir", ".devlyn",
|
|
51
|
+
"--phase", phase, *args], cwd=self.work, env=ENV, capture_output=True, text=True,
|
|
52
|
+
)
|
|
53
|
+
if error:
|
|
54
|
+
self.assertNotEqual(result.returncode, 0, result.stdout)
|
|
55
|
+
self.assertIn(error, result.stderr)
|
|
56
|
+
self.assertEqual(self.state_path.read_bytes(), before)
|
|
57
|
+
else:
|
|
58
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
def assert_owner(self, phase):
|
|
62
|
+
entry = self.state()["phases"][phase]
|
|
63
|
+
self.assertEqual(entry["execution_kind"],
|
|
64
|
+
"orchestrator_context" if phase == "plan" else "orchestrator_commands")
|
|
65
|
+
for name in ("engine", "model_requested", "model_effective"):
|
|
66
|
+
self.assertIsNone(entry[name])
|
|
67
|
+
self.assertFalse({"prompt_sha256", "invocation_receipt", "role_argv"} & entry.keys())
|
|
68
|
+
return entry
|
|
69
|
+
|
|
70
|
+
def plan(self):
|
|
71
|
+
self.cli("plan", "spawn", "--round", "0")
|
|
72
|
+
self.assert_owner("plan")
|
|
73
|
+
(self.devlyn / "plan.md").write_text(
|
|
74
|
+
'<!-- devlyn:authorized-surface -->\n# Files\n```json\n'
|
|
75
|
+
'{"authorized_surface":["source.txt"]}\n```\n# Acceptance\nPreserve scope.\n')
|
|
76
|
+
|
|
77
|
+
def test_plan_digest_and_atomic_handoff(self):
|
|
78
|
+
self.plan()
|
|
79
|
+
self.cli("plan", "transition", "--verdict", "PASS", "--next-phase", "implement",
|
|
80
|
+
"--next-round", "0", "--next-engine", "claude")
|
|
81
|
+
entry = self.assert_owner("plan")
|
|
82
|
+
self.assertEqual(entry["output_sha256"],
|
|
83
|
+
hashlib.sha256((self.devlyn / "plan.md").read_bytes()).hexdigest())
|
|
84
|
+
self.cli("plan", "spawn", "--round", "1", "--triggered-by", "plan",
|
|
85
|
+
error="plan-already-in-use")
|
|
86
|
+
(self.devlyn / "plan.md").write_text("widened scope")
|
|
87
|
+
self.cli("implement", "complete", "--verdict", "PASS", error="plan-integrity-mismatch")
|
|
88
|
+
|
|
89
|
+
def test_owner_identity_and_current_round_artifacts(self):
|
|
90
|
+
for phase in ("plan", "cleanup"):
|
|
91
|
+
self.save({"version": "3.0", "run_id": "rs-owner-test", "engine": "codex", "phases": {}})
|
|
92
|
+
spawn = ("spawn", "--round", "0") + (("--pre-sha", self.head) if phase == "cleanup" else ())
|
|
93
|
+
self.cli(phase, *spawn)
|
|
94
|
+
opened = self.state()
|
|
95
|
+
for flag in ("--engine", "--model", "--engine-session-log"):
|
|
96
|
+
self.cli(phase, "complete", "--verdict", "BLOCKED", flag, "false-worker",
|
|
97
|
+
error="cannot claim worker")
|
|
98
|
+
for suffix in ("prompt.0", "worker-session.0.jsonl", "invocation.0.json", "argv.0.json"):
|
|
99
|
+
proof = self.devlyn / f"{phase}.{suffix}"
|
|
100
|
+
proof.symlink_to(self.devlyn / "missing")
|
|
101
|
+
self.cli(phase, "complete", "--verdict", "BLOCKED", error="worker evidence")
|
|
102
|
+
proof.unlink()
|
|
103
|
+
for key, value in (("engine", "codex"), ("prompt_sha256", None),
|
|
104
|
+
("execution_kind", False), ("execution_kind", {})):
|
|
105
|
+
bad = json.loads(json.dumps(opened))
|
|
106
|
+
bad["phases"][phase][key] = value
|
|
107
|
+
self.save(bad)
|
|
108
|
+
self.cli(phase, "complete", "--verdict", "BLOCKED", error="error:")
|
|
109
|
+
self.save(opened)
|
|
110
|
+
|
|
111
|
+
def test_owner_plan_history_is_bound_and_round_limited(self):
|
|
112
|
+
self.plan()
|
|
113
|
+
self.cli("plan", "complete", "--verdict", "NEEDS_WORK")
|
|
114
|
+
old = self.assert_owner("plan")
|
|
115
|
+
self.cli("plan", "spawn", "--round", "1", "--triggered-by", "plan")
|
|
116
|
+
self.assertEqual(self.state()["phases"]["plan"]["history"][0]["output_sha256"], old["output_sha256"])
|
|
117
|
+
self.cli("plan", "complete", "--verdict", "BLOCKED")
|
|
118
|
+
self.cli("plan", "spawn", "--round", "2", "--triggered-by", "plan", error="plan-respawn-exhausted")
|
|
119
|
+
|
|
120
|
+
def test_cleanup_checks_source_and_preserves_atomicity(self):
|
|
121
|
+
self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
|
|
122
|
+
self.assert_owner("cleanup")
|
|
123
|
+
args = ("transition", "--verdict", "PASS", "--post-sha", self.head,
|
|
124
|
+
"--next-phase", "verify", "--next-round", "0", "--next-engine", "claude")
|
|
125
|
+
(self.work / "source.txt").write_text("unverified change\n")
|
|
126
|
+
self.cli("cleanup", *args, error="owner-cleanup-source-changed")
|
|
127
|
+
self.git("add", "source.txt")
|
|
128
|
+
self.cli("cleanup", *args, error="owner-cleanup-source-changed")
|
|
129
|
+
self.git("commit", "-qm", "unverified")
|
|
130
|
+
self.cli("cleanup", *args, error="owner-cleanup-source-changed")
|
|
131
|
+
# All mutations are in this disposable test fixture.
|
|
132
|
+
self.git("reset", "--hard", self.head)
|
|
133
|
+
self.cli("cleanup", *args)
|
|
134
|
+
self.assertIsNotNone(self.state()["phases"]["verify"]["started_at"])
|
|
135
|
+
|
|
136
|
+
def test_cleanup_repair_reenters_build_with_durability(self):
|
|
137
|
+
self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
|
|
138
|
+
(self.devlyn / "cleanup.findings.jsonl").write_text("")
|
|
139
|
+
self.cli("cleanup", "transition", "--verdict", "FAIL", "--post-sha", self.head,
|
|
140
|
+
"--next-phase", "implement", "--next-round", "1", "--next-triggered-by", "cleanup",
|
|
141
|
+
"--next-engine", "claude")
|
|
142
|
+
self.cli("implement", "transition", "--verdict", "PASS", "--next-phase", "build_gate",
|
|
143
|
+
"--next-round", "1", error="closure-durability")
|
|
144
|
+
self.git("commit", "--allow-empty", "-qm", "chore(pipeline): implement fix round 1")
|
|
145
|
+
self.cli("implement", "durability-enforce", "--round", "1", "--origin-phase", "cleanup")
|
|
146
|
+
self.cli("implement", "transition", "--verdict", "PASS", "--next-phase", "build_gate", "--next-round", "1")
|
|
147
|
+
self.assert_owner("build_gate")
|
|
148
|
+
|
|
149
|
+
def test_dirty_cleanup_can_fail_honestly_but_cannot_verify(self):
|
|
150
|
+
self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
|
|
151
|
+
opened = self.state_path.read_bytes()
|
|
152
|
+
(self.work / "source.txt").write_text("BUILD left a tracked change\n")
|
|
153
|
+
self.cli("cleanup", "transition", "--verdict", "FAIL", "--post-sha", self.head,
|
|
154
|
+
"--next-phase", "verify", "--next-round", "0", error="repair before VERIFY")
|
|
155
|
+
self.cli("cleanup", "complete", "--verdict", "BLOCKED", "--post-sha", self.head)
|
|
156
|
+
self.assertEqual(self.state()["phases"]["cleanup"]["verdict"], "BLOCKED")
|
|
157
|
+
self.state_path.write_bytes(opened)
|
|
158
|
+
self.cli("cleanup", "transition", "--verdict", "FAIL", "--post-sha", self.head,
|
|
159
|
+
"--next-phase", "implement", "--next-round", "1", "--next-triggered-by", "cleanup",
|
|
160
|
+
"--next-engine", "claude")
|
|
161
|
+
self.assertEqual((self.work / "source.txt").read_text(), "BUILD left a tracked change\n")
|
|
162
|
+
|
|
163
|
+
def test_final_untracked_delta_cannot_bypass_scope(self):
|
|
164
|
+
(self.work / "user-existing.txt").write_text("preserve")
|
|
165
|
+
(self.devlyn / "untracked.baseline").write_text("user-existing.txt\n")
|
|
166
|
+
self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
|
|
167
|
+
(self.work / "outside-plan.txt").write_text("leak")
|
|
168
|
+
self.cli("cleanup", "complete", "--verdict", "PASS", "--post-sha", self.head,
|
|
169
|
+
error="owner-cleanup-untracked")
|
|
170
|
+
(self.work / "outside-plan.txt").unlink()
|
|
171
|
+
(self.devlyn / "untracked.baseline").unlink()
|
|
172
|
+
self.cli("cleanup", "complete", "--verdict", "PASS", "--post-sha", self.head,
|
|
173
|
+
error="owner-cleanup-untracked")
|
|
174
|
+
(self.devlyn / "untracked.baseline").write_text("user-existing.txt\n")
|
|
175
|
+
self.cli("cleanup", "complete", "--verdict", "PASS", "--post-sha", self.head)
|
|
176
|
+
self.assertEqual((self.work / "user-existing.txt").read_text(), "preserve")
|
|
177
|
+
|
|
178
|
+
def test_owner_cleanup_does_not_replace_selected_worker(self):
|
|
179
|
+
helper = runpy.run_path(str(SHARED / "state-phase-write.py"))
|
|
180
|
+
(self.devlyn / "engines.json").write_text(json.dumps({"roles": {
|
|
181
|
+
"worker": {"engine": "codex", "model": "gpt-6-astra", "effort": "high"}}}))
|
|
182
|
+
state = self.state()
|
|
183
|
+
frozen = helper["freeze_roles"](state, self.work, "codex")
|
|
184
|
+
self.save(state)
|
|
185
|
+
self.cli("cleanup", "spawn", "--round", "0", "--pre-sha", self.head)
|
|
186
|
+
self.assert_owner("cleanup")
|
|
187
|
+
self.assertEqual(self.state()["role_resolution"], frozen)
|
|
188
|
+
self.cli("implement", "spawn", "--round", "0", "--engine", "claude",
|
|
189
|
+
error="role-selection-mismatch")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
unittest.main(verbosity=2)
|