devlyn-cli 2.9.2 → 2.9.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.
package/README.md CHANGED
@@ -27,7 +27,7 @@ If devlyn-cli saved you time, [give it a star](https://github.com/fysoul17/devly
27
27
  npx devlyn-cli
28
28
  ```
29
29
 
30
- That's it. The installer opens with a single **agent selector** — pick any combination of **Claude Code**, **Codex CLI**, **oh-my-pi (omp)**, or **Pi**, and devlyn installs into every one you choose in a single pass. Claude Code and any agent already present on your machine are pre-checked, so the common case is just Enter. Skill-capable agents receive the `devlyn:resolve`, `devlyn:ideate`, and `devlyn:design-ui` skills — plus the `devlyn:engines` and `devlyn:queue` utilities — in the directory each one loads from: Codex → `~/.codex/skills/`, while **omp and Pi share `~/.agents/skills/`** — the cross-agent standard both read — so the bundle is written there once, not duplicated per agent. In Codex / omp / Pi, invoke them as skills (`$devlyn:resolve`, `$devlyn:ideate`, `$devlyn:design-ui`); in Claude Code they're slash commands (`/devlyn:resolve`). Run it again anytime to update. (`npx devlyn-cli -y` installs the Claude core non-interactively; `npx devlyn-cli agents <cli>` adds one agent later.)
30
+ That's it. The installer opens with a single **agent selector** — pick any combination of **Claude Code**, **Codex CLI**, **oh-my-pi (omp)**, **Pi**, or **Grok Build CLI**, and devlyn installs into every one you choose in a single pass. Claude Code and any agent already present on your machine are pre-checked, so the common case is just Enter. Skill-capable agents receive the `devlyn:resolve`, `devlyn:ideate`, and `devlyn:design-ui` skills — plus the `devlyn:engines` and `devlyn:queue` utilities — in the directory each one loads from: Codex → `~/.codex/skills/`, Grok → `~/.grok/skills/`, while **omp and Pi share `~/.agents/skills/`** — the cross-agent standard both read — so the bundle is written there once, not duplicated per agent. In Codex / omp / Pi, invoke them as skills (`$devlyn:resolve`, `$devlyn:ideate`, `$devlyn:design-ui`); in Claude Code and Grok Build CLI they're slash commands (`/devlyn:resolve`). Run it again anytime to update. (`npx devlyn-cli -y` installs the Claude core non-interactively; `npx devlyn-cli agents <cli>` adds one agent later.)
31
31
 
32
32
  ---
33
33
 
@@ -39,7 +39,7 @@ devlyn-cli turns your AI coding agent into a hands-free development pipeline. Th
39
39
  ideate (optional) → resolve → ship
40
40
  ```
41
41
 
42
- Non-Claude agents (Codex / omp / Pi): when one of these is selected, the workflows install as that agent's skills. Use `$devlyn:ideate`, `$devlyn:resolve`, or `$devlyn:design-ui`; the `/devlyn:*` slash-command form is for Claude Code.
42
+ Non-Claude agents (Codex / omp / Pi / Grok): when one of these is selected, the workflows install as that agent's skills. In Codex / omp / Pi, use `$devlyn:ideate`, `$devlyn:resolve`, or `$devlyn:design-ui`; in Grok, use `/devlyn:ideate`, `/devlyn:resolve`, or `/devlyn:design-ui`, the same slash-command form as Claude Code.
43
43
 
44
44
  ### Step 1 (optional) — Plan with `/devlyn:ideate`
45
45
 
package/bin/devlyn.js CHANGED
@@ -58,6 +58,19 @@ const CLI_TARGETS = {
58
58
  // Project-scoped only (see omp): machine-level ~/.pi must not auto-trigger.
59
59
  detect: () => fs.existsSync(path.join(process.cwd(), '.pi')) || fs.existsSync(path.join(process.cwd(), '.agents')),
60
60
  },
61
+ grok: {
62
+ name: 'Grok Build CLI (xAI)',
63
+ instructionsFile: 'AGENTS.md',
64
+ baseInstructionsFile: 'AGENTS.md',
65
+ // Grok discovers same-format SKILL.md skills from ./.grok/skills >
66
+ // <repo_root>/.grok/skills > ~/.grok/skills > ~/.claude/skills
67
+ // (Claude-compatible); skills are slash-invocable in the Grok TUI.
68
+ skillsDir: path.join(os.homedir(), '.grok', 'skills'),
69
+ skillsToInstall: DEVLYN_CORE_SKILLS,
70
+ // Project-scoped only: machine-level ~/.grok must not auto-trigger a project
71
+ // AGENTS.md write via `npx devlyn-cli agents` in an unrelated repo.
72
+ detect: () => fs.existsSync(path.join(process.cwd(), '.grok')),
73
+ },
61
74
  };
62
75
 
63
76
  // Files removed in previous versions that should be cleaned up on upgrade
@@ -94,22 +94,19 @@ def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
94
94
  (devlyn / "verify-merge.summary.json").unlink(missing_ok=True)
95
95
 
96
96
 
97
- def append_round_history(entry: dict) -> None:
97
+ def append_phase_history(entry: dict) -> None:
98
98
  if entry.get("started_at") is None:
99
99
  return
100
- history = entry.get("rounds_history")
100
+ history = entry.get("history")
101
101
  if not isinstance(history, list):
102
102
  history = []
103
103
  history.append({
104
- "round": entry.get("round"),
105
104
  "started_at": entry.get("started_at"),
105
+ "verdict": entry.get("verdict"),
106
106
  "completed_at": entry.get("completed_at"),
107
107
  "duration_ms": entry.get("duration_ms"),
108
- "verdict": entry.get("verdict"),
109
- "triggered_by": entry.get("triggered_by"),
110
- "engine": entry.get("engine"),
111
108
  })
112
- entry["rounds_history"] = history
109
+ entry["history"] = history
113
110
 
114
111
 
115
112
  def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
@@ -121,7 +118,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
121
118
  if not isinstance(entry, dict):
122
119
  entry = {}
123
120
  phases[phase] = entry
124
- append_round_history(entry)
121
+ append_phase_history(entry)
125
122
  entry["started_at"] = now_iso()
126
123
  entry["completed_at"] = None
127
124
  entry["duration_ms"] = None
@@ -197,6 +194,7 @@ def self_test() -> int:
197
194
  do_complete(state, "implement", "NEEDS_WORK", None, None, None, None, "test-model-id")
198
195
  write_state(state_path, state)
199
196
  entry = read_state(state_path)["phases"]["implement"]
197
+ assert "history" not in entry, "history must be absent before re-entry"
200
198
  assert entry["completed_at"] is not None
201
199
  assert entry["duration_ms"] >= 0
202
200
  assert parse_iso(entry["completed_at"]) >= parse_iso(entry["started_at"])
@@ -216,15 +214,13 @@ def self_test() -> int:
216
214
  assert respawned["engine"] == "claude", "engine preserved when not re-supplied"
217
215
  assert respawned["round"] == 1
218
216
  assert respawned["triggered_by"] == "verify"
219
- assert len(respawned["rounds_history"]) == 1
220
- history0 = respawned["rounds_history"][0]
221
- assert history0["round"] == 0
217
+ assert len(respawned["history"]) == 1
218
+ history0 = respawned["history"][0]
222
219
  assert history0["started_at"] == round0_started
220
+ assert history0["verdict"] == "NEEDS_WORK"
223
221
  assert history0["completed_at"] == entry["completed_at"]
224
222
  assert history0["duration_ms"] == entry["duration_ms"]
225
- assert history0["verdict"] == "NEEDS_WORK"
226
- assert history0["triggered_by"] is None
227
- assert history0["engine"] == "claude"
223
+ assert set(history0) == {"started_at", "verdict", "completed_at", "duration_ms"}
228
224
 
229
225
  time.sleep(0.05)
230
226
  state = read_state(state_path)
@@ -262,9 +258,9 @@ def self_test() -> int:
262
258
  write_state(state_path, state)
263
259
  respawned_fail = read_state(state_path)["phases"]["build_gate"]
264
260
  assert respawned_fail["verdict"] is None
265
- assert len(respawned_fail["rounds_history"]) == 1
266
- assert respawned_fail["rounds_history"][0]["verdict"] == "FAIL"
267
- assert respawned_fail["rounds_history"][0]["completed_at"] == failed_round["completed_at"]
261
+ assert len(respawned_fail["history"]) == 1
262
+ assert respawned_fail["history"][0]["verdict"] == "FAIL"
263
+ assert respawned_fail["history"][0]["completed_at"] == failed_round["completed_at"]
268
264
 
269
265
  # VERIFY flow: verify-merge-findings.py already wrote verdict; complete()
270
266
  # must preserve it when --verdict is omitted.
@@ -368,8 +364,8 @@ def self_test() -> int:
368
364
  respawned_exec = read_state(state_path)["phases"]["implement"]
369
365
  assert respawned_exec["exec"]["current"] == 3, "spawn must not clobber unowned fields like exec"
370
366
  assert respawned_exec["verdict"] is None, "respawn still nulls owned fields even with exec present"
371
- assert len(respawned_exec["rounds_history"]) == 1
372
- assert respawned_exec["rounds_history"][0]["verdict"] == "PASS"
367
+ assert len(respawned_exec["history"]) == 1
368
+ assert respawned_exec["history"][0]["verdict"] == "PASS"
373
369
 
374
370
  # Existing history is append-only; a respawn must not clobber prior
375
371
  # entries that were already preserved from older rounds.
@@ -383,14 +379,11 @@ def self_test() -> int:
383
379
  "triggered_by": "verify",
384
380
  "verdict": "FAIL",
385
381
  "engine": "codex",
386
- "rounds_history": [{
387
- "round": 1,
382
+ "history": [{
388
383
  "started_at": "2026-01-01T00:00:00.000Z",
384
+ "verdict": "FAIL",
389
385
  "completed_at": "2026-01-01T00:00:01.000Z",
390
386
  "duration_ms": 1000,
391
- "verdict": "FAIL",
392
- "triggered_by": "build_gate",
393
- "engine": "claude",
394
387
  }],
395
388
  }
396
389
  }
@@ -398,11 +391,10 @@ def self_test() -> int:
398
391
  state = read_state(state_path)
399
392
  do_spawn(state, "implement", 3, "verify", None, None, None)
400
393
  write_state(state_path, state)
401
- history_preserved = read_state(state_path)["phases"]["implement"]["rounds_history"]
394
+ history_preserved = read_state(state_path)["phases"]["implement"]["history"]
402
395
  assert len(history_preserved) == 2
403
- assert history_preserved[0]["round"] == 1
404
- assert history_preserved[1]["round"] == 2
405
- assert history_preserved[1]["engine"] == "codex"
396
+ assert history_preserved[0]["started_at"] == "2026-01-01T00:00:00.000Z"
397
+ assert history_preserved[1]["started_at"] == "2026-01-01T00:00:02.000Z"
406
398
 
407
399
  # complete() records a post-state commit when a bounded phase needs an
408
400
  # exact diff window for later mechanical checks.
@@ -92,7 +92,7 @@ Once `state.implement_passed_sha` is non-null (PHASE 2 returned and produced a d
92
92
 
93
93
  `--pair-verify` and `--no-pair` are mutually exclusive; if both are present, stop with `BLOCKED:invalid-flags`. Likewise `--goal-file` combined with `--spec`, `--verify-only`, or an inline positional goal — or a missing/repeated `--goal-file` value — stops with `BLOCKED:invalid-flags`. (These `BLOCKED:<reason>` strings are report-level only; the JSON `phases.*.verdict` carrier in `pipeline.state.json` always stays the bare enum `"BLOCKED"`.)
94
94
 
95
- 3. Initialize `.devlyn/pipeline.state.json` per `references/state-schema.md`. Set `state.run_id`, `started_at`, `engine`, `engine_source` (`"flag"` | `"engines.json"` | `"default"`), `pair_verify: true` only when `--pair-verify` was passed and `false` otherwise, `base_ref.{branch, sha}`, `rounds.{max_rounds, global: 0}`, `bypasses`, empty `phases`, empty `criteria`, and `risk_profile: { high_risk: false, reasons: [], risk_probes_enabled: false, pair_default_enabled: true }`. `risk_profile` is strict typed state: keep it an object, keep the three flags as JSON booleans, and keep `reasons` as a string array; never serialize booleans as strings.
95
+ 3. Initialize `.devlyn/pipeline.state.json` per `references/state-schema.md`. Set `state.run_id`, `started_at`, `engine`, `engine_source` (`"flag"` | `"engines.json"` | `"default"`), `pair_verify: true` only when `--pair-verify` was passed and `false` otherwise, `base_ref.{branch, sha}`, `rounds.{max_rounds, global: 0}`, `bypasses`, empty `phases`, empty `criteria`, and `risk_profile: { high_risk: false, reasons: [], risk_probes_enabled: false, risk_probes_explicit: false, pair_default_enabled: true }`. `risk_profile` is strict typed state: keep it an object, keep the four flags as JSON booleans, and keep `reasons` as a string array; never serialize booleans as strings.
96
96
 
97
97
  4. Write `.devlyn/untracked.baseline`: `python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --write-untracked-baseline`.
98
98
 
@@ -101,7 +101,7 @@ Once `state.implement_passed_sha` is non-null (PHASE 2 returned and produced a d
101
101
  - **Spec**: validate spec exists. If sibling `spec.expected.json` exists, run `--check-expected <expected-path>` to validate both the expected contract, 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`. Then stage `.devlyn/spec-verify.json` from `verification_commands`. Otherwise run `--check <spec-path>` to validate the legacy inline carrier plus supported `complexity` frontmatter and any present actionable solo-headroom hypothesis; if the spec uses an inline `## Verification` JSON carrier, any solo-headroom hypothesis command must match that carrier's `verification_commands[].cmd`. Then stage from the legacy inline carrier. Compute `state.source.spec_sha256`.
102
102
  - **Verify-only**: skip to PHASE 5 with `state.source.spec_path` set, the supplied diff captured at `.devlyn/external-diff.patch`.
103
103
 
104
- 6. Compute `state.risk_profile` from the user goal plus spec/criteria text. Mark `high_risk: true` when the work touches any of: auth/authz, permissions, security, token/session, payment/money/billing/invoice/pricing/tax/ledger, persistence/data mutation/deletion/migration, idempotency/replay/duplicate, API/webhook/raw-body/signature, allocation/scheduling/inventory/rollback/transaction, or explicit error-priority/output-shape contracts. Explicit `--risk-probes` sets `risk_probes_enabled: true` unconditionally (the OTHER engine is then a promise; unavailability blocks at PHASE 1.5). If high-risk is detected and `--no-risk-probes` is absent, this is an AUTO escalation: set `risk_probes_enabled: true` only when a pair-judge engine is available per role resolution (first adapter-valid, non-primary, available entry of `pair_judge_priority`; binary complement when unset — for a Claude run without a config, `command -v codex`); if none is available, leave `risk_probes_enabled: false`, keep `high_risk: true`, and append a reason such as `auto-risk-probes skipped: codex-unavailable` so the report shows it. `--no-risk-probes` disables only the auto escalation; a single-engine run does not need it to proceed. If `--no-pair` is present, set `pair_default_enabled: false`. Add concise string reasons for the classification, but do not use reasons as substitutes for the boolean fields.
104
+ 6. Compute `state.risk_profile` from the user goal plus spec/criteria text. Mark `high_risk: true` when the work touches any of: auth/authz, permissions, security, token/session, payment/money/billing/invoice/pricing/tax/ledger, persistence/data mutation/deletion/migration, idempotency/replay/duplicate, API/webhook/raw-body/signature, allocation/scheduling/inventory/rollback/transaction, or explicit error-priority/output-shape contracts. Explicit `--risk-probes` sets `risk_probes_enabled: true` and `risk_probes_explicit: true` unconditionally (the OTHER engine is then a promise; unavailability blocks at PHASE 1.5). If high-risk is detected and `--no-risk-probes` is absent, this is an AUTO escalation: set `risk_probes_enabled: true` only when a pair-judge engine is available per role resolution (first adapter-valid, non-primary, available entry of `pair_judge_priority`; binary complement when unset — for a Claude run without a config, `command -v codex`); if none is available, leave `risk_probes_enabled: false`, keep `high_risk: true`, and append a reason such as `auto-risk-probes skipped: codex-unavailable` so the report shows it. `--no-risk-probes` disables only the auto escalation; a single-engine run does not need it to proceed. If `--no-pair` is present, set `pair_default_enabled: false`. Add concise string reasons for the classification, but do not use reasons as substitutes for the boolean fields.
105
105
 
106
106
  7. Announce one line: `resolve starting — run <run_id> — engine <engine> — mode <mode> — complexity <complexity-or-na> — pair <conditional|disabled> — risk_probes <on|off>`.
107
107
 
@@ -118,6 +118,7 @@ State write: `phases.plan.{started_at, verdict, completed_at, duration_ms}`.
118
118
  After return:
119
119
  1. If `.devlyn/plan.md` lists zero files → halt with verdict `BLOCKED:plan-empty`.
120
120
  2. If risk list flags an out-of-scope expansion the user did not authorize → re-spawn once with the reminder; second fail → halt.
121
+ 3. After any re-spawn above, if `state.risk_profile.risk_probes_enabled == true` and `state.risk_profile.risk_probes_explicit == false`, parse the `authorized_surface` array from the JSON block under `<!-- devlyn:authorized-surface -->`. For a well-formed string array, compute `probe_scale_small := len(authorized_surface) <= 2 AND no entry ends in "/**"`. If true, set `risk_probes_enabled = false`, leave `high_risk` unchanged, and append `auto-risk-probes demoted: plan surface small (<n> paths)` to `reasons` using the actual length. A missing or malformed block leaves probe state unchanged; BUILD_GATE owns its malformed-block failure.
121
122
 
122
123
  ## PHASE 1.5: RISK_PROBES
123
124
 
@@ -340,4 +341,4 @@ State write: `phases.final_report.started_at` at the top of this phase.
340
341
 
341
342
  ## State management
342
343
 
343
- `.devlyn/pipeline.state.json` is the single authoritative verdict source. Branch on `state.phases.<name>.verdict` directly; never parse `.devlyn/*.findings.jsonl` for routing decisions. Schema and write protocol: `references/state-schema.md`. Every "State write: `phases.X.{...}`" note above describes the resulting shape only — the orchestrator always writes it via `state-phase-write.py spawn`/`complete` (`references/state-schema.md#write-protocol`); phase workers never edit `pipeline.state.json` directly.
344
+ `.devlyn/pipeline.state.json` is the single authoritative verdict source. Branch on `state.phases.<name>.verdict` directly; never parse `.devlyn/*.findings.jsonl` for routing decisions. Schema and write protocol: `references/state-schema.md`. Every "State write: `phases.X.{...}`" note above describes the resulting shape only — the orchestrator always writes it via `state-phase-write.py spawn`/`complete`, whose re-entry path preserves the prior `started_at`, `verdict`, `completed_at`, and `duration_ms` in `history[]` before resetting them (`references/state-schema.md#write-protocol`); phase workers never edit `pipeline.state.json` directly.
@@ -22,6 +22,8 @@ Compute these signals from the goal text + project state:
22
22
  phrase `solo ceiling avoidance`, mention `solo_claude`, and name a concrete
23
23
  difference from rejected or solo-saturated controls such as `S2`-`S6`?
24
24
 
25
+ Evaluate Large first, then Medium, then Trivial; stop at the first matching branch.
26
+
25
27
  ### Trivial branch
26
28
 
27
29
  Conditions (all must hold):
@@ -37,7 +39,7 @@ Action: synthesize a minimal internal spec from the goal:
37
39
  ### Medium branch
38
40
 
39
41
  Conditions (any one):
40
- - `goal_length` between 30 and 80 words.
42
+ - `goal_length > 30` words.
41
43
  - `file_scope_signals` between 4 and 10.
42
44
  - `verb_class ∈ {refactor, debug, review}` AND scope is a single subsystem.
43
45
  - `has_failing_test == false` but the goal implies a runnable acceptance check.
@@ -50,7 +52,6 @@ Action: synthesize a richer internal spec:
50
52
  ### Large branch
51
53
 
52
54
  Conditions (any one):
53
- - `goal_length > 80` words.
54
55
  - `file_scope_signals > 10` OR zero signals (vague enough that the classifier cannot pick scope).
55
56
  - `verb_class ∈ {rewrite, migrate}` and scope is multi-subsystem.
56
57
  - The goal mentions a new feature whose surface area requires design decisions the harness cannot make from a one-shot prompt.
@@ -13,7 +13,7 @@ Single authoritative verdict source for `/devlyn:resolve`. The orchestrator bran
13
13
  "mode": "spec",
14
14
  "pair_verify": false,
15
15
  "complexity": null,
16
- "risk_profile": { "high_risk": false, "reasons": [], "risk_probes_enabled": false, "pair_default_enabled": true },
16
+ "risk_profile": { "high_risk": false, "reasons": [], "risk_probes_enabled": false, "risk_probes_explicit": false, "pair_default_enabled": true },
17
17
  "risk_probes_digest": null,
18
18
  "base_ref": { "branch": "main", "sha": "abc123..." },
19
19
  "rounds": { "max_rounds": 4, "global": 0 },
@@ -51,7 +51,7 @@ Single authoritative verdict source for `/devlyn:resolve`. The orchestrator bran
51
51
  - **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`.
52
52
  - **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`.
53
53
  - **source** — provenance for the contract all downstream phases read. Spec and verify-only mode set `type: "spec"`, `spec_path`, and `spec_sha256`. Free-form mode sets `type: "generated"`, leaves `spec_path`/`spec_sha256` null, and must set `criteria_path: ".devlyn/criteria.generated.md"` plus `criteria_sha256` from the generated file's raw bytes. VERIFY re-checks the matching hash before judging.
54
- - **risk_profile** — PHASE 0 classification for conditional defaults. `high_risk` records durable-risk signals from the goal/spec; `risk_probes_enabled` is true for explicit `--risk-probes` or high-risk specs unless `--no-risk-probes`; `pair_default_enabled` is false only for explicit `--no-pair`. `risk_profile` must remain an object with boolean `high_risk`, `risk_probes_enabled`, and `pair_default_enabled` fields when present, plus `reasons` as a list of strings. Malformed `risk_profile` blocks VERIFY because pair-trigger reasons derive `risk.high` and `risk_probes.enabled` from this state.
54
+ - **risk_profile** — PHASE 0 classification for conditional defaults. `high_risk` records durable-risk signals from the goal/spec; `risk_probes_enabled` starts true for explicit `--risk-probes` or eligible high-risk specs and only an automatic small-surface route may demote it after PLAN; `risk_probes_explicit` is true only for explicit `--risk-probes`, preventing that demotion from breaking the promise; `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 list of strings. Malformed `risk_profile` blocks VERIFY because pair-trigger reasons derive `risk.high` and `risk_probes.enabled` from this state.
55
55
  - **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.
56
56
  - **rounds.global** — incremented every fix-loop pass (BUILD_GATE → fix-loop, VERIFY → fix-loop, OR a phase-gate fix respawn inside phase-gated IMPLEMENT).
57
57
  - **phases.implement.exec** — only on phase-gated large runs (plan.md has `## Execution phases` with >1 phase): `{ "total": N, "current": k, "statuses": ["PASS"|"FAIL"|null, ...], "commits": ["<sha>", ...] }`. Phase definitions live in plan.md (immutable contract); progress lives here (routing truth — never route on plan.md checkboxes). Absent on single-phase runs. `cumulative.patch` for such runs is `git diff <base_ref.sha>...HEAD`.
@@ -80,17 +80,17 @@ Each entry under `phases.<name>` (for `plan`, `probe_derive`, `implement`, `buil
80
80
  "pre_sha": null,
81
81
  "post_sha": null,
82
82
  "artifacts": { "findings_file": null, "log_file": null },
83
- "sub_verdicts": null,
84
- "rounds_history": []
83
+ "sub_verdicts": null
85
84
  }
86
85
  ```
87
86
 
87
+ `history` is absent until a phase is re-entered. Immediately before re-entry overwrites the live `started_at`, `verdict`, `completed_at`, and `duration_ms`, `state-phase-write.py` appends those prior values as one object to `history[]`, creating the append-only array on first re-entry.
88
+
88
89
  - `verdict` — `"PASS" | "PASS_WITH_ISSUES" | "FAIL" | "NEEDS_WORK" | "BLOCKED"`. PHASE 6 (FINAL_REPORT) writes its own verdict per the terminal-verdict precedence.
89
90
  - `triggered_by` — null on first run; one of `"build_gate" | "verify"` when the phase is a fix-loop respawn.
90
91
  - `pre_sha` — captured by orchestrator before CLEANUP and (if needed) other allowlist-enforced phases. Used to validate the post-spawn diff.
91
92
  - `post_sha` — captured on CLEANUP complete when that phase ran; finish-gate subtracts `pre_sha..post_sha` as the cleanup window.
92
93
  - `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).
93
- - `rounds_history` — appended by `spawn` on respawn: the superseded round's `{round, started_at, completed_at, duration_ms, verdict, triggered_by, engine}`. Failed/superseded round wall time is read from here, never lost to the reset.
94
94
  - `merged` — only populated for VERIFY after `verify-merge-findings.py --write-state`: `{ "verdict": "...", "findings_file": ".devlyn/verify-merged.findings.jsonl", "summary_file": ".devlyn/verify-merge.summary.json" }`.
95
95
  - `pair_trigger` — only populated for VERIFY; same shape as top-level `verify.pair_trigger` when the phase stores it locally.
96
96
  - `correctness.risk-probe-failed` — emitted by `spec-verify-check.py --include-risk-probes` when an executable probe derived from the visible `## Verification` section fails.
@@ -99,7 +99,7 @@ Each entry under `phases.<name>` (for `plan`, `probe_derive`, `implement`, `buil
99
99
 
100
100
  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.
101
101
 
102
- 1. **Spawn** (before every phase dispatch, including a fix-loop respawn — one call per outer phase-gated IMPLEMENT run, not per inner sub-phase): `python3 "$DEVLYN_SHARED_DIR/state-phase-write.py" --devlyn-dir .devlyn --phase <name> spawn --round <N> [--triggered-by build_gate|verify] [--pre-sha <sha>] [--engine <e>] [--model <m>]`. If the existing phase record has `started_at`, appends the superseded round to `rounds_history`, then resets `started_at` to now and nulls `completed_at`/`duration_ms`/`verdict`/`artifacts`/`sub_verdicts` — a respawn can never inherit a prior round's stale timing or verdict, and prior round timing remains attributable. A VERIFY spawn also deletes the prior round's on-disk VERIFY artifacts (`verify*.jsonl`, `verify-merge.summary.json`, `*-judge.*`) so a stale pair findings file can never read as current-round spawn evidence in `verify-merge-findings.py`. Fields the script doesn't own (e.g. `phases.implement.exec`, the phase-gated progress tracker) are left untouched.
102
+ 1. **Spawn** (before every phase dispatch, including a fix-loop respawn — one call per outer phase-gated IMPLEMENT run, not per inner sub-phase): `python3 "$DEVLYN_SHARED_DIR/state-phase-write.py" --devlyn-dir .devlyn --phase <name> spawn --round <N> [--triggered-by build_gate|verify] [--pre-sha <sha>] [--engine <e>] [--model <m>]`. Re-entry applies the `history` rule above, then resets `started_at` to now and nulls `completed_at`/`duration_ms`/`verdict`/`artifacts`/`sub_verdicts`. A VERIFY spawn also deletes the prior round's on-disk VERIFY artifacts (`verify*.jsonl`, `verify-merge.summary.json`, `*-judge.*`) so stale findings cannot read as current-round evidence. Fields the script doesn't own (e.g. `phases.implement.exec`) are left untouched.
103
103
  2. **Complete** (after the orchestrator reads the phase's reply and determines the verdict): `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>]`. `duration_ms` is derived from the phase's own recorded `started_at` — it cannot drift from `completed_at`. `--verdict` is required for every phase except VERIFY: `verify-merge-findings.py --write-state` is the sole writer of `phases.verify.verdict`, so `complete verify` is always called without `--verdict` (an explicit one is rejected) and preserves what's already on disk.
104
104
  3. **Before archive** (PHASE 6 step 5): `phases.final_report.verdict` must be non-null. Archive prune skips runs whose final_report verdict is null (treated as in-flight).
105
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "2.9.2",
3
+ "version": "2.9.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": {