devlyn-cli 2.9.3 → 2.10.1

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.
Files changed (40) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +5 -5
  3. package/README.md +5 -5
  4. package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
  5. package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
  6. package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
  7. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
  8. package/bin/devlyn.js +108 -15
  9. package/config/skills/_shared/adapters/README.md +2 -2
  10. package/config/skills/_shared/adapters/grok.md +99 -0
  11. package/config/skills/_shared/archive_run.py +35 -1
  12. package/config/skills/_shared/collect-codex-findings.py +231 -50
  13. package/config/skills/_shared/engine-doctor.sh +6 -3
  14. package/config/skills/_shared/engine-preflight.md +12 -5
  15. package/config/skills/_shared/expected.schema.json +7 -0
  16. package/config/skills/_shared/grok-anchor-guard.py +61 -0
  17. package/config/skills/_shared/phase-prompt-render.py +181 -0
  18. package/config/skills/_shared/resolve-bootstrap.py +630 -0
  19. package/config/skills/_shared/resolve-stop-hook.py +325 -0
  20. package/config/skills/_shared/spec-verify-check.py +308 -31
  21. package/config/skills/_shared/state-phase-write.py +2966 -27
  22. package/config/skills/_shared/terminal-claim-check.py +537 -0
  23. package/config/skills/_shared/verify-merge-findings.py +533 -16
  24. package/config/skills/devlyn:engines/SKILL.md +2 -2
  25. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  26. package/config/skills/devlyn:queue/SKILL.md +12 -0
  27. package/config/skills/devlyn:resolve/SKILL.md +60 -78
  28. package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
  29. package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
  30. package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
  31. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
  32. package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
  33. package/config/skills/devlyn:resolve/references/phases/verify.md +70 -97
  34. package/config/skills/devlyn:resolve/references/state-schema.md +26 -19
  35. package/optional-skills/devlyn:reap/SKILL.md +1 -1
  36. package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
  37. package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
  38. package/package.json +1 -1
  39. package/scripts/lint-fixtures.sh +4 -1
  40. package/scripts/lint-skills.sh +269 -77
@@ -82,33 +82,36 @@ design/style concerns remain non-binding MEDIUM and produce `PASS_WITH_ISSUES`.
82
82
 
83
83
  **Anti-self-filter rule**: report every finding you observe, including ones you consider low-severity or low-confidence. Tag each with `confidence: high|medium|low` and let the harness's downstream filter rank them. Filtering at this stage suppresses recall.
84
84
 
85
- ### Pair-mode (when triggered by orchestrator)
86
-
87
- Pair-mode is eligible only after MECHANICAL and the primary JUDGE have no
88
- verdict-binding findings. Deterministic blockers and primary JUDGE blockers
89
- already decide the verdict and route to the fix loop; a second judge there
90
- duplicates evidence and wastes wall-time. If MECHANICAL or the primary JUDGE
91
- has a verdict-binding finding, record `pair_judge: null` and do not spawn the
92
- second VERIFY agent.
93
-
94
- When eligible, trigger pair-mode if any of these are true:
95
- - `state.pair_verify == true` (`--pair-verify` was set).
96
- - `state.mode == "verify-only"`.
97
- - The spec frontmatter has `complexity: high`; legacy/external spec
98
- `complexity: large` is accepted for compatibility, but new specs use `high`.
99
- - Current free-form `state.complexity` is `"large"`; legacy `"high"` state remains accepted by the merge validator only for archived run compatibility.
100
- - `state.risk_profile.high_risk == true`.
101
- - `.devlyn/risk-probes.jsonl` exists or `state.risk_profile.risk_probes_enabled == true`.
102
- - The spec includes an actionable solo-headroom hypothesis.
103
- - MECHANICAL or the primary JUDGE emitted warning-level findings but no
104
- verdict-binding blockers.
105
- - `state.verify.coverage_failed == true`.
85
+ ### Pair-mode (default when OTHER engine is available)
86
+
87
+ MECHANICAL runs first. A verdict-binding blocker routes to the fix loop without
88
+ either judge and records `pair_judge: null`. After it passes, honor `--no-pair`;
89
+ otherwise resolve the OTHER-engine route. An explicit `--pair-verify` is an
90
+ availability promise: an unavailable OTHER engine BLOCKs. An automatic route
91
+ with no OTHER engine records `auto_pair_other_engine_unavailable` and continues
92
+ solo.
93
+
94
+ When the OTHER engine is available, write `pair_trigger` at judge-spawn time
95
+ with `pair.default` plus every applicable outcome-independent canonical reason,
96
+ then dispatch the primary and pair JUDGEs concurrently against the same frozen
97
+ diff via foreground parallel dispatch, never background shells. A primary blocker does not cancel the pair-JUDGE; both finding sets join the same fix
98
+ round and merge by worst source verdict without vote counting. An orchestrator
99
+ without foreground parallel dispatch runs the same two required judges
100
+ sequentially, with no flag or extra state marker. This is a dispatch-shape
101
+ fallback, not outcome-dependent escalation; the primary cannot skip the pair.
102
+ Prefer read-only checks and serialize probes requiring exclusive shared state.
103
+
104
+ Keep these reasons as telemetry; they no longer gate the second spawn:
105
+ `mode.pair-verify`, `mode.verify-only`, `complexity.high`,
106
+ `complexity.large`, `spec.complexity.high`, `spec.complexity.large`,
107
+ `spec.solo_headroom_hypothesis`, `risk.high`, `risk_probes.enabled`,
108
+ `risk_probes.present`, `coverage.failed`, `mechanical.warning`, and
109
+ `judge.warning`.
106
110
 
107
111
  Malformed `state.risk_profile` is a VERIFY contract violation: it must be an
108
112
  object, `high_risk` / `risk_probes_enabled` / `pair_default_enabled` must be
109
- JSON booleans when present, and `reasons` must be a string array. Do not treat
110
- missing or malformed risk state as low-risk; `verify-merge-findings.py` blocks
111
- it because it can hide `risk.high` or `risk_probes.enabled` pair triggers.
113
+ JSON booleans when present, and `reasons` must be a string array. The merge
114
+ blocks malformed state because it can hide a required route.
112
115
 
113
116
  If `--no-pair` was set, do not spawn the OTHER-engine judge. Record
114
117
  `pair_trigger: { eligible: false, reasons: [], skipped_reason: "user_no_pair" }`
@@ -116,58 +119,32 @@ and continue with solo VERIFY. This is an explicit user opt-out, not an engine
116
119
  availability fallback. `--pair-verify` and `--no-pair` are mutually exclusive;
117
120
  if both are present, stop with `BLOCKED:invalid-flags`.
118
121
 
119
- After MECHANICAL and the primary JUDGE finish, compute and persist this before
120
- spawning the OTHER-engine pair judge:
122
+ When the OTHER engine is available, persist this before either judge starts:
121
123
 
122
124
  ```json
123
125
  "pair_trigger": {
124
126
  "eligible": true,
125
- "reasons": ["complexity.high"],
127
+ "reasons": ["pair.default", "complexity.high"],
126
128
  "skipped_reason": null
127
129
  }
128
130
  ```
129
131
 
130
- If `eligible == true`, `reasons` must be non-empty and include every applicable canonical reason; for example, a spec with an actionable solo-headroom
131
- hypothesis must include `spec.solo_headroom_hypothesis` even when another reason
132
- such as `risk.high` also applies. The OTHER-engine judge is mandatory. Skipping
133
- it is a VERIFY contract violation. If ineligible, record the
134
- reason, e.g. `"mechanical_blocker"` or `"primary_judge_blocker"`.
135
-
136
- `pair_trigger` is a strict contract, not advisory metadata. `eligible: true`
137
- requires a non-empty `reasons` list and `skipped_reason: null`; `eligible: false`
138
- requires an empty `reasons` list and a string/null `skipped_reason`. Do not emit
139
- contradictory states such as `eligible: true` with `skipped_reason`, or
140
- `eligible: false` with trigger reasons. `verify-merge-findings.py` blocks VERIFY
141
- on malformed trigger state. Eligible triggers must contain only canonical
142
- reasons and at least one reason: `mode.verify-only`, `complexity.high`, `complexity.large`,
143
- `mode.pair-verify`, `spec.complexity.high`, `spec.complexity.large`,
144
- `spec.solo_headroom_hypothesis`, `risk.high`, `risk_probes.enabled`,
145
- `risk_probes.present`, `coverage.failed`, `mechanical.warning`, or
146
- `judge.warning`.
132
+ After both judges return, append every applicable outcome-dependent reason
133
+ (`coverage.failed`, `mechanical.warning`, `judge.warning`) before merge. Eligible schema-v3 state must contain `pair.default` and every other applicable canonical
134
+ reason. The OTHER-engine judge is mandatory; missing output is a contract
135
+ violation.
136
+
137
+ `pair_trigger` remains strict: eligible state has canonical reasons and no skip;
138
+ ineligible state has empty reasons and one of `user_no_pair`,
139
+ `mechanical_blocker`, `auto_pair_other_engine_unavailable`, or null.
140
+ `primary_judge_blocker` is parser-recognized only for archived v2.0 replay and
141
+ retains the existing rejection when a pre-known reason applied. New runs never
142
+ write it. The `--engine` flag does not disable default pairing.
147
143
 
148
- The `--engine` flag never disables this rule. An explicit `--engine <name>`
149
- names the primary judge; if pair-mode triggers, the first available OTHER
150
- engine is still the mandatory pair judge (role resolution:
151
- `_shared/engine-preflight.md`). Do not record "explicit --engine <name>" as a
152
- skip reason.
153
- The valid skip reasons after a non-empty eligible trigger are deterministic
154
- MECHANICAL HIGH/CRITICAL blockers, an explicit `--no-pair`, or — when none of the
155
- applicable reasons is `mode.pair-verify` (every reason is automatic) — the OTHER
156
- engine being unavailable. Explicit `--pair-verify` with the OTHER engine absent
157
- is a promise and stays `BLOCKED:<engine>-unavailable`, never a skip.
158
-
159
- Before invoking the OTHER-engine judge, run the shared availability pre-flight
160
- for that engine. If the route is explicit (`--pair-verify`) and Codex is required
161
- and unavailable, set VERIFY to `BLOCKED:codex-unavailable` and tell the user to
162
- install/configure the Codex CLI, run the current Codex auth/login flow, verify
163
- `codex --version`, and rerun. If Claude is required and the host cannot spawn a
164
- Claude agent, set VERIFY to `BLOCKED:claude-unavailable` and tell the user to
165
- install/configure Claude Code, verify `claude --version`, and rerun. For an
166
- explicit route, do not convert this to a solo pass and do not synthesize pair
167
- findings. When the trigger was AUTO-only and the OTHER engine is unavailable, set
168
- `eligible: false`, `reasons: []`, `skipped_reason:
169
- "auto_pair_other_engine_unavailable"`, run solo VERIFY, and report the skipped
170
- auto escalation — route selection, not a fallback.
144
+ Run the shared availability pre-flight before spawn. Explicit `--pair-verify`
145
+ unavailability stays `BLOCKED:<engine>-unavailable` with setup guidance.
146
+ Automatic unavailability records `auto_pair_other_engine_unavailable` and runs
147
+ solo. Never synthesize pair findings.
171
148
 
172
149
  When eligible and the orchestrator spawns a second VERIFY agent with the OTHER engine's adapter, both judgments are merged:
173
150
  - Any HIGH/CRITICAL finding either model surfaces is verdict-binding.
@@ -200,7 +177,11 @@ When eligible and the orchestrator spawns a second VERIFY agent with the OTHER e
200
177
  A targeted probe must compare the full externally visible result
201
178
  (stdout/stderr/exit and full parsed output object, including accepted/scheduled
202
179
  rows, rejected rows, and remaining state when present), not just a single
203
- property. When the spec names exact keys, row shapes, JSON object shape, or an
180
+ property. The mandatory dominance-loss anchor must run as a single command
181
+ with no shell chain operators. Chains used only to re-capture exit status are
182
+ forbidden because the tool result already carries it; this does not ban
183
+ compound shell generally or multi-step probes issued as separate tool calls.
184
+ When the spec names exact keys, row shapes, JSON object shape, or an
204
185
  exact error body, compare parsed key sets/deep equality so aliased keys,
205
186
  missing keys, and extra keys are verdict-binding failures. Use the spec's
206
187
  visible input key names literally when constructing the probe input. For
@@ -221,39 +202,31 @@ When eligible and the orchestrator spawns a second VERIFY agent with the OTHER e
221
202
  and the assertion must cover the complete output ordering for both accepted
222
203
  (or scheduled) and rejected rows.
223
204
 
224
- Codex pair-JUDGE is read-only. Invoke `codex-monitored.sh` directly with
205
+ The resolved pair-JUDGE is read-only. Codex keeps the monitored
206
+ `codex-monitored.sh` route with
225
207
  `CODEX_MONITORED_ISOLATED=1 CODEX_MONITORED_TIMEOUT_SEC=600` and
226
- `-c model_reasoning_effort=medium`; this is a
227
- bounded two-probe review, not implementation. Isolation blocks user config,
228
- AGENTS.md, hooks, and project rules from hidden context/tool
229
- side effects. Do not pipe it to `tail`, `head`, `grep`, `sed`, or `awk`.
230
- Capture stdout/stderr directly. The Codex judge must return JSONL findings on
231
- stdout; the orchestrator writes `.devlyn/verify.pair.findings.jsonl` and merges
232
- verdicts. Do not ask Codex to `apply_patch` or edit `.devlyn`.
233
- When the OTHER engine is Claude (codex/omp orchestrator), the judge call
234
- follows `_shared/adapters/claude.md` `## Invocation`: wrap headless `claude -p`
235
- as `python3 "$DEVLYN_SHARED_DIR/run-bounded.py" 600 -- claude -p ...` with
236
- `--permission-mode dontAsk`, an allowlist of `Read,Grep,Glob` plus the repo test
237
- command, hermetic settings, stdout captured to
238
- `.devlyn/claude-judge.stdout`. The same bounded-output contract and emission
239
- rule apply to that stdout file, and the orchestrator writes the same
240
- canonical `.devlyn/verify.pair.findings.jsonl`.
241
- The Codex prompt must include a bounded-output contract: no harness-doc reads,
208
+ `-c model_reasoning_effort=medium`; isolation blocks user config, AGENTS.md,
209
+ hooks, and project rules from hidden context/tool side effects. Do not pipe it
210
+ to `tail`, `head`, `grep`, `sed`, or `awk`; capture stdout/stderr directly.
211
+ Every other resolved OTHER engine follows `_shared/adapters/<name>.md`
212
+ `## Invocation`. Capture the result as `.devlyn/<name>-judge.stdout`, then run
213
+ `python3 "$DEVLYN_SHARED_DIR/collect-codex-findings.py" --devlyn-dir
214
+ "<abs repo>/.devlyn" --stdout-file <name>-judge.stdout` before merge.
215
+ The orchestrator writes the canonical `.devlyn/verify.pair.findings.jsonl`.
216
+ The pair prompt must include a bounded-output contract: no harness-doc reads,
242
217
  maximum two targeted probes before first output, stop on the first
243
218
  verdict-binding finding, and emit PASS immediately after the bounded checks pass.
244
- If stdout is first captured to `.devlyn/codex-judge.stdout`, run
245
- `python3 "$DEVLYN_SHARED_DIR/collect-codex-findings.py"` before merge. That
246
- script is the deterministic boundary writer for
247
- `.devlyn/verify.pair.findings.jsonl`.
248
- If raw Codex stdout is captured as `.devlyn/codex-judge.stdout`,
249
- `verify-merge-findings.py` treats it as a diagnostic only. If stdout contains
250
- findings or a non-PASS summary while `.devlyn/verify.pair.findings.jsonl` is
251
- empty, VERIFY is `BLOCKED` for `verify.pair.emission-contract`; do not pass or
252
- silently recover from a broken capture contract.
253
-
254
- Both pair-judge directions are wall-budgeted at 600s. A judge subprocess exit
219
+ Raw stdout is diagnostic-only. A non-zero collector exit must write no
220
+ canonical findings file: set the pair source to `BLOCKED` for
221
+ `verify.pair.emission-contract`, and do not merge as though unparsed stdout were
222
+ only a diagnostic. Do not ask the judge to edit `.devlyn`.
223
+
224
+ Both pair-judge directions are wall-budgeted at 600s. The orchestrator records
225
+ `state.phases.verify.judge_durations_ms: {"judge": <int>, "pair_judge": <int|null>}`
226
+ as it collects each judge result; these wall durations are siblings of, never
227
+ nested inside, normalized string `sub_verdicts`. A judge subprocess exit
255
228
  124 is a budget abort: before merge, the orchestrator writes
256
- `.devlyn/verify.pair.timeout.json` with `{"engine": "<codex|claude>",
229
+ `.devlyn/verify.pair.timeout.json` with `{"engine": "<resolved name>",
257
230
  "budget_seconds": 600}`. Three cases are binding: marker plus no canonical pair
258
231
  findings and no parseable stdout findings records `pair_judge: "TIMEOUT"`,
259
232
  computes the merged verdict from mechanical plus primary judge, and surfaces
@@ -6,7 +6,7 @@ Single authoritative verdict source for `/devlyn:resolve`. The orchestrator bran
6
6
 
7
7
  ```json
8
8
  {
9
- "version": "2.0",
9
+ "version": "3.0",
10
10
  "run_id": "rs-<UTC-timestamp>-<12-hex>",
11
11
  "started_at": "2026-04-30T12:00:00Z",
12
12
  "engine": "claude",
@@ -33,6 +33,7 @@ Single authoritative verdict source for `/devlyn:resolve`. The orchestrator bran
33
33
  "plan": null,
34
34
  "probe_derive": null,
35
35
  "implement": null,
36
+ "surface_close": null,
36
37
  "build_gate": null,
37
38
  "cleanup": null,
38
39
  "verify": null,
@@ -45,27 +46,29 @@ Single authoritative verdict source for `/devlyn:resolve`. The orchestrator bran
45
46
  ## Field rules
46
47
 
47
48
  - **version** — string. Bump major on a breaking schema change.
49
+ - **session_id** — optional string or null stamped from `CLAUDE_CODE_SESSION_ID`; a missing/null value leaves Stop-hook pressure inert.
48
50
  - **mode** — `"free-form" | "spec" | "verify-only"`.
49
- - **pair_verify** — boolean. Set true only when the user passed `--pair-verify`; otherwise false. This is the durable state evidence for the `mode.pair-verify` pair-trigger reason. It is mutually exclusive with `risk_profile.pair_default_enabled == false` from `--no-pair`; `verify-merge-findings.py` blocks the contradictory state.
51
+ - **pair_verify** — boolean. True only for `--pair-verify`. Pairing is already default-when-available; this flag makes OTHER-engine availability an explicit fail-closed promise and adds `mode.pair-verify` telemetry. It is mutually exclusive with `risk_profile.pair_default_enabled == false` from `--no-pair`.
50
52
  - **complexity** — `null | "trivial" | "medium" | "large"`. Free-form mode populates this; spec/verify-only mode leaves it null.
51
53
  - **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
54
  - **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
- - **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` 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
+ - **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.
56
+ - **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.
55
57
  - **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
58
  - **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
59
  - **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`.
58
- - **phases.probe_derive** — optional PHASE 1.5 entry when `--risk-probes` is enabled. Artifacts include `.devlyn/risk-probes.jsonl`. Probe failures later surface through BUILD_GATE/VERIFY as `correctness.risk-probe-failed`.
60
+ - **phases.probe_derive** — optional PHASE 1.5 entry when `--risk-probes` is enabled. Artifacts include `.devlyn/risk-probes.jsonl`. Probe failures later surface through BUILD_GATE/VERIFY as `correctness.risk-probe-failed`, or as `correctness.verification-timeout` when the probe exceeds its verification budget.
61
+ - **phases.surface_close** — generated trivial/medium one-shot: `pre_sha`, `input_patch_sha256`, assembled-byte `prompt_sha256`, `untracked_before`; spawn requires Claude plus a requested model, and `surface-check` gates PASS. `.devlyn/surface-close.output.json` retains the Claude JSON wrapper used for completion attestation while the round JSONL remains the execution-audit backstop. Automatic Claude unavailability alone records null verdict plus `skipped_reason: "auto_surface_close_claude_unavailable"`. After an adjudication-malformed reply only, successful rollback plus transcript write-audit records the two nonterminal fields `skipped_reason: "surface_close_rolled_back_adjudication_malformed"` and `continued_after_block: true`; neither creates a terminal floor. Other failures store bare `"BLOCKED"`. `durability[]` is append-only: `{round, origin_phase, receipt_path, receipt_sha256, restore_commit_sha}` per `.devlyn/closure-durability.round-<n>.json`.
59
62
  - **bypasses** — array of phase names from `--bypass`. Valid: `"build-gate" | "cleanup"`. PLAN, IMPLEMENT, VERIFY are non-bypassable (orchestrator rejects at parse time).
60
- - **implement_passed_sha** — captured at end of PHASE 2; null until then. Activates the post-implement invariant for CLEANUP and VERIFY.
63
+ - **implement_passed_sha** — captured at end of PHASE 2; null until then. Activates the post-implement invariant for SURFACE_CLOSE, CLEANUP, and VERIFY.
61
64
  - **criteria** — generated from spec's `## Requirements` checklist (one per `- [ ]`). `status: pending → implemented` is the legal transition. `failed_by_finding_ids` populates when VERIFY surfaces a finding tied to a criterion.
62
65
  - **`.devlyn/untracked.baseline` artifact** — written at PHASE 0 (`spec-verify-check.py --write-untracked-baseline`); BUILD_GATE flags created-during-run unauthorized untracked files against it (`.devlyn/` exempt; semantics in `phases/build-gate.md` step 4).
63
- - **verify.coverage_failed** — set by VERIFY's JUDGE sub-phase when a spec axis could not be exercised against the diff. Triggers pair-mode escalation when set. Pair-mode also triggers for `state.pair_verify == true`, verify-only mode, high-risk specs, active risk probes, actionable solo-headroom hypotheses, `complexity: high` specs, or current free-form `state.complexity` of `"large"` when MECHANICAL and the primary JUDGE have no verdict-binding blockers. Legacy/external spec `complexity: large` remains accepted for compatibility; new specs use `high`. Legacy `"high"` state remains accepted by the merge validator only for archived run compatibility.
64
- - **verify.pair_trigger** — VERIFY's trigger decision: `{ "eligible": boolean, "reasons": string[], "skipped_reason": string|null }`. The shape is strict: `eligible: true` requires a non-empty reasons list containing every applicable canonical eligible reason and only canonical eligible reasons, plus `skipped_reason: null`; `eligible: false` requires an empty reasons list and may set only `user_no_pair`, `mechanical_blocker`, `primary_judge_blocker`, `auto_pair_other_engine_unavailable`, or null as the skip cause. Canonical eligible reasons are `mode.verify-only`, `mode.pair-verify`, `complexity.high`, `complexity.large`, `spec.complexity.high`, `spec.complexity.large`, `spec.solo_headroom_hypothesis`, `risk.high`, `risk_probes.enabled`, `risk_probes.present`, `coverage.failed`, `mechanical.warning`, and `judge.warning`. `user_no_pair` is valid only when `risk_profile.pair_default_enabled == false` from an explicit `--no-pair`; `mechanical_blocker` and `primary_judge_blocker` are valid only when the matching source has a verdict-binding finding; `auto_pair_other_engine_unavailable` is valid only when every applicable trigger reason is automatic (none is `mode.pair-verify`) and no pair-judge engine is available per role resolution (pre-iter-0038 SKILL.md already used this skip; the schema previously omitted it — reconciled). If state implies a pair decision is required but `pair_trigger` is missing, if it records `eligible:false` with no supported skip reason, if an eligible trigger omits an applicable reason such as `spec.solo_headroom_hypothesis`, or if any combination is malformed, `verify-merge-findings.py` blocks VERIFY.
66
+ - **verify.coverage_failed** — outcome-dependent telemetry set when JUDGE cannot exercise a spec axis. It no longer gates pair dispatch: after MECHANICAL passes, `pair.default` dispatches both judges concurrently whenever `pair_default_enabled != false` and the OTHER engine is available. An orchestrator without foreground parallel dispatch runs the same two required judges sequentially; a primary blocker never skips the pair. A verdict-binding MECHANICAL blocker still skips both. Legacy complexity values remain accepted only for archived compatibility.
67
+ - **verify.pair_trigger** — strict decision state: `{ "eligible": boolean, "reasons": string[], "skipped_reason": string|null }`. Schema v3.0 eligible state requires `pair.default` plus every applicable telemetry reason and `skipped_reason: null`; outcome-dependent reasons are appended after both judges return. Canonical reasons are `pair.default`, `mode.verify-only`, `mode.pair-verify`, `complexity.high`, `complexity.large`, `spec.complexity.high`, `spec.complexity.large`, `spec.solo_headroom_hypothesis`, `risk.high`, `risk_probes.enabled`, `risk_probes.present`, `coverage.failed`, `mechanical.warning`, and `judge.warning`. Ineligible new-run state has empty reasons and only `user_no_pair`, `mechanical_blocker`, `auto_pair_other_engine_unavailable`, or null; `primary_judge_blocker` remains parser-recognized only for archived v2.0 replay and retains its existing pre-known-reason rejection. Explicit `mode.pair-verify` cannot use the unavailable-engine skip. Missing, contradictory, incomplete, or unknown trigger state BLOCKs VERIFY.
65
68
 
66
69
  ## Per-phase shape
67
70
 
68
- Each entry under `phases.<name>` (for `plan`, `probe_derive`, `implement`, `build_gate`, `cleanup`, `verify`, `final_report`):
71
+ Each entry under `phases.<name>` (for `plan`, `probe_derive`, `implement`, `surface_close`, `build_gate`, `cleanup`, `verify`, `final_report`):
69
72
 
70
73
  ```json
71
74
  {
@@ -76,7 +79,8 @@ Each entry under `phases.<name>` (for `plan`, `probe_derive`, `implement`, `buil
76
79
  "triggered_by": null,
77
80
  "verdict": "PASS",
78
81
  "engine": "claude",
79
- "model": "<exact model id that ran this phase>",
82
+ "model_requested": "<orchestrator-selected model id or null>",
83
+ "model_effective": "<session-attested model id or null>",
80
84
  "pre_sha": null,
81
85
  "post_sha": null,
82
86
  "artifacts": { "findings_file": null, "log_file": null },
@@ -87,10 +91,12 @@ Each entry under `phases.<name>` (for `plan`, `probe_derive`, `implement`, `buil
87
91
  `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
92
 
89
93
  - `verdict` — `"PASS" | "PASS_WITH_ISSUES" | "FAIL" | "NEEDS_WORK" | "BLOCKED"`. PHASE 6 (FINAL_REPORT) writes its own verdict per the terminal-verdict precedence.
94
+ - `model_requested` / `model_effective` — `model_requested` is supplied by the orchestrator through `--model`; `model_effective` is parsed only from completion-time engine session evidence. Codex evidence accepts the `model: <id>` exec header or, for `.jsonl`, rollout `turn_context.payload.model`; Claude JSON output accepts a singleton `modelUsage` key or, for multiple entries, the sole key whose `inputTokens`, `outputTokens`, `cacheReadInputTokens`, and `cacheCreationInputTokens` exactly match top-level `usage.input_tokens`, `usage.output_tokens`, `usage.cache_read_input_tokens`, and `usage.cache_creation_input_tokens`. Missing or malformed counters, zero or multiple matches, and requested/effective mismatch fail closed: the phase verdict becomes `BLOCKED` and the completion command fails. For mutation phases, omission of `--engine-session-log` also fails closed when `.devlyn/<artifact-phase>.worker-session.<round>.jsonl` exists; the error names that retained file. Otherwise omission records `model_effective: null` and attests that the engine class exposed no session log for that invocation.
90
95
  - `triggered_by` — null on first run; one of `"build_gate" | "verify"` when the phase is a fix-loop respawn.
91
- - `pre_sha` — captured by orchestrator before CLEANUP and (if needed) other allowlist-enforced phases. Used to validate the post-spawn diff.
92
- - `post_sha` — captured on CLEANUP complete when that phase ran; finish-gate subtracts `pre_sha..post_sha` as the cleanup window.
96
+ - `pre_sha` — captured before SURFACE_CLOSE or CLEANUP. It bounds that phase's post-spawn diff and rollback.
97
+ - `post_sha` — captured when SURFACE_CLOSE or CLEANUP completes; finish-gate subtracts CLEANUP's `pre_sha..post_sha` window.
93
98
  - `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).
99
+ - `judge_durations_ms` — only populated for VERIFY: `{ "judge": <non-negative int>, "pair_judge": <non-negative int|null> }`. The orchestrator writes each wall duration when it collects that judge's result; values remain a sibling of `sub_verdicts`, whose values stay normalized strings. VERIFY spawn resets this key to null so re-entry cannot retain prior-round timings.
94
100
  - `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
101
  - `pair_trigger` — only populated for VERIFY; same shape as top-level `verify.pair_trigger` when the phase stores it locally.
96
102
  - `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,9 +105,10 @@ Each entry under `phases.<name>` (for `plan`, `probe_derive`, `implement`, `buil
99
105
 
100
106
  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
107
 
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
- 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
- 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).
108
+ 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>]`. Re-entry preserves lifecycle in `history[]`, resets owned fields, and leaves fields such as `phases.implement.exec`; VERIFY also clears prior-round files. SURFACE_CLOSE additionally requires `--model <requested-model> --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.
109
+ 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. The verb returns JSON paths/digests/state facts only and never chooses a phase, engine, branch, prompt, or agent.
110
+ 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 the invocation's own session evidence whenever it exists; SURFACE_CLOSE passes `.devlyn/surface-close.output.json` as `--engine-session-log` so its `modelUsage` attests completion. Omission records `model_effective: null` only when the current phase/round has no retained worker-session file. Before completing a mutation phase (`implement`, `surface_close` when present, or `cleanup`), copy the exact session JSONL identified by that phase's dispatch receipt to `.devlyn/<artifact-phase>.worker-session.<round>.jsonl`, where `<artifact-phase>` is `implement`, `surface-close`, or `cleanup`; multiple rounds remain separate. Never discover or copy sessions by scanning an engine-global session directory. If that current-round file exists but `--engine-session-log` is omitted, completion fails closed and names the file. `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 normal 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; model-attestation failure still forces the phase to `BLOCKED`.
111
+ 4. **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
112
 
106
113
  ## Terminal verdict (PHASE 6)
107
114
 
@@ -117,12 +124,12 @@ Precedence:
117
124
 
118
125
  Header: `run_id | engine | mode | complexity | verdict | wall_time_s`.
119
126
 
120
- Per-phase summary table: `phase | verdict | duration_ms | round | triggered_by | findings_count`.
127
+ Per-phase summary table: `phase | verdict | duration_ms | round | triggered_by | findings_count`; include SURFACE_CLOSE when run or skipped.
121
128
 
122
- Findings table (post-IMPLEMENT phases only — they are findings-only): each finding's `severity | rule_id | file:line | message | confidence`.
129
+ Findings table: each emitted finding's `severity | rule_id | file:line | message | confidence`.
123
130
 
124
- Follow-up notes: any large-mode assumptions, any pair-judge TIMEOUT surfaced as `solo verdict after pair TIMEOUT`, pair/risk-probe opt-out state, engine setup guidance for `BLOCKED:<engine>-unavailable`, `/devlyn:ideate` guidance for `BLOCKED:solo-headroom-hypothesis-required` that asks for the visible behavior `solo_claude` is expected to miss, `/devlyn:ideate` guidance for `BLOCKED:solo-ceiling-avoidance-required` that asks for the concrete difference from rejected or solo-saturated controls such as `S2`-`S6`, and any `state.verify.coverage_failed` axes.
131
+ Follow-up notes: when `phases.surface_close.continued_after_block` is true, exactly one explicit `pipeline continued to BUILD_GATE — surface_close_rolled_back_adjudication_malformed` line (never a terminal floor); any large-mode assumptions; any pair-judge TIMEOUT surfaced as `solo verdict after pair TIMEOUT`; pair/risk-probe opt-out state; engine setup guidance for `BLOCKED:<engine>-unavailable`; `/devlyn:ideate` guidance for `BLOCKED:solo-headroom-hypothesis-required` that asks for the visible behavior `solo_claude` is expected to miss; `/devlyn:ideate` guidance for `BLOCKED:solo-ceiling-avoidance-required` that asks for the concrete difference from rejected or solo-saturated controls such as `S2`-`S6`; and any `state.verify.coverage_failed` axes.
125
132
 
126
133
  ## Archive contract
127
134
 
128
- PHASE 6 step 4 moves the per-run artifact set (`PER_RUN_PATTERNS` in `archive_run.py` — the single source of truth) into `.devlyn/runs/<run_id>/`. Machine config such as `.devlyn/engines.json` is not a run artifact and stays in place. The `.devlyn/runs/` directory keeps the last 10 completed runs (sorted by `started_at`). Best-effort prune; archive failure does not change the run's verdict.
135
+ PHASE 6 step 4 moves `PER_RUN_PATTERNS` into `.devlyn/runs/<run_id>/`, including Goal, SURFACE_CLOSE patch/prompt/reply, and mutation worker-session JSONL; it never scans engine-global sessions. Machine config stays; the last 10 completed runs remain. Archive/prune failure does not change verdict.
@@ -95,7 +95,7 @@ These are the ONLY categories reap.sh will touch:
95
95
 
96
96
  | Category | Match | Why safe |
97
97
  |---|---|---|
98
- | `telegram-bun` | `bun server.ts` **AND** cwd contains `/plugins/cache/claude-plugins-official/telegram/` | Telegram MCP plugin leaks one per Claude session. Verified by cwd, not just cmdline. |
98
+ | `telegram-bun` | worker `bun server.ts` **AND** cwd under `…/claude-plugins-official/…telegram` (matches both the old `plugins/cache/` and current `plugins/marketplaces/…/external_plugins/` layouts); PLUS the runner root `bun run --cwd …/claude-plugins-official/…telegram … start`, verified by its own cmdline (its cwd is the session dir). Workers ignore SIGTERM (measured 2026-07-17: 23/23 survived TERM, died on KILL) — reap.sh sends SIGKILL for this category by default. | Telegram MCP plugin leaks one runner+worker tree per Claude session; at ~24 trees the workers (21-43% CPU each) saturated an 11-core machine (loadavg 100+). |
99
99
  | `superset-codex-bash` | `/bin/bash .*/.superset/bin/codex` with PPID=1 | `.superset/bin/codex` wrapper exits without killing its tail child; bash copies left behind. |
100
100
  | `superset-codex-tail` | `tail -F .*superset-codex-session-*.jsonl` with PPID=1 | Log tail from the same wrapper, always safe to stop. |
101
101
  | `workerd` (opt-in) | `@cloudflare/workerd-darwin-*/bin/workerd serve ` with PPID=1 | moonmaker-engine dev server that survives tab close. Opt-in because the user may have an active dev session. |
@@ -47,16 +47,23 @@ collect_pids() {
47
47
  local category="$1"
48
48
  case "$category" in
49
49
  telegram-bun)
50
- # cwd-verified — same logic as scan.sh
51
- printf '%s\n' "$SNAPSHOT" \
52
- | grep -E '/bun[^ ]* server\.ts( |$)' \
53
- | awk '{print $1}' \
54
- | while read -r pid; do
55
- cwd="$(lsof -a -d cwd -p "$pid" 2>/dev/null | awk 'NR==2 {for(i=9;i<=NF;i++) printf "%s ", $i; print ""}')"
56
- case "$cwd" in
57
- *"/plugins/cache/claude-plugins-official/telegram/"*) printf '%s\n' "$pid" ;;
58
- esac
59
- done
50
+ # Worker form: cwd-verified — same logic (and same both-layouts pattern)
51
+ # as scan.sh. Runner form: cmdline names the plugin path = verification
52
+ # (its cwd is the session dir, not the plugin dir).
53
+ {
54
+ printf '%s\n' "$SNAPSHOT" \
55
+ | grep -E '/bun[^ ]* server\.ts( |$)' \
56
+ | awk '{print $1}' \
57
+ | while read -r pid; do
58
+ cwd="$(lsof -a -d cwd -p "$pid" 2>/dev/null | awk 'NR==2 {for(i=9;i<=NF;i++) printf "%s ", $i; print ""}')"
59
+ case "$cwd" in
60
+ *"/claude-plugins-official/"*telegram*) printf '%s\n' "$pid" ;;
61
+ esac
62
+ done
63
+ printf '%s\n' "$SNAPSHOT" \
64
+ | grep -E '( |/)bun[^ ]* run --cwd [^ ]*/claude-plugins-official/[^ ]*telegram[^ ]*( |$)' \
65
+ | awk '{print $1}'
66
+ } | sort -u
60
67
  ;;
61
68
  superset-codex-bash)
62
69
  printf '%s\n' "$SNAPSHOT" | grep -E '/bin/bash .*/\.superset/bin/codex( |$)' | awk '{print $1}' ;;
@@ -108,11 +115,16 @@ for cat in "${CATS_ARR[@]}"; do
108
115
  TOTAL_SKIPPED=$((TOTAL_SKIPPED+1))
109
116
  continue
110
117
  fi
118
+ # telegram-bun workers IGNORE SIGTERM (measured 2026-07-17: 23/23 survived
119
+ # TERM, all died on KILL — the leak's root behavior is "never exits").
120
+ # TERM against them is a proven no-op, so this category defaults to KILL.
121
+ sig="$SIGNAL"
122
+ [ "$cat" = "telegram-bun" ] && sig="KILL"
111
123
  if [ "$DRY" -eq 1 ]; then
112
- printf '[%s] %s would SIG%s\n' "$cat" "$pid" "$SIGNAL"
124
+ printf '[%s] %s would SIG%s\n' "$cat" "$pid" "$sig"
113
125
  else
114
- if kill -s "$SIGNAL" "$pid" 2>/dev/null; then
115
- printf '[%s] %s SIG%s sent\n' "$cat" "$pid" "$SIGNAL"
126
+ if kill -s "$sig" "$pid" 2>/dev/null; then
127
+ printf '[%s] %s SIG%s sent\n' "$cat" "$pid" "$sig"
116
128
  TOTAL_KILLED=$((TOTAL_KILLED+1))
117
129
  else
118
130
  printf '[%s] %s kill failed\n' "$cat" "$pid"
@@ -21,6 +21,10 @@ SNAPSHOT="$(ps -eo pid=,ppid=,user=,etime=,command= 2>/dev/null | awk -v me="$ME
21
21
  # Conservative by design — if unsure, leave it UNKNOWN.
22
22
  # -----------------------------------------------------------------------------
23
23
  match_telegram_bun() { grep -E '/bun[^ ]* server\.ts( |$)'; }
24
+ # Runner form of the same leak (2026-07-17: ~24 found at loadavg 100+): the
25
+ # `bun run --cwd <plugin> … start` root outlives its session; its cmdline
26
+ # NAMES the plugin path, so the cmdline itself is the verification.
27
+ match_telegram_run() { grep -E '( |/)bun[^ ]* run --cwd [^ ]*/claude-plugins-official/[^ ]*telegram[^ ]*( |$)'; }
24
28
  match_superset_codex_sh() { grep -E '/bin/bash .*/\.superset/bin/codex( |$)'; }
25
29
  match_superset_codex_tl() { grep -E 'tail .*superset-codex-session-.*\.jsonl'; }
26
30
  match_workerd_dev() { grep -E '@cloudflare/workerd-darwin-[^/]+/bin/workerd serve '; }
@@ -50,12 +54,22 @@ if [ -n "$SNAPSHOT" ]; then
50
54
  BUN_CANDIDATES="$(printf '%s\n' "$SNAPSHOT" | match_telegram_bun | awk '{print $1}')"
51
55
  for pid in $BUN_CANDIDATES; do
52
56
  cwd="$(lsof -a -d cwd -p "$pid" 2>/dev/null | awk 'NR==2 {for(i=9;i<=NF;i++) printf "%s ", $i; print ""}')"
57
+ # One pattern covers both plugin layouts: old plugins/cache/…/telegram/
58
+ # and current plugins/marketplaces/…/external_plugins/telegram (the old
59
+ # cache-only pattern missed the live layout — 2026-07-17).
53
60
  case "$cwd" in
54
- *"/plugins/cache/claude-plugins-official/telegram/"*)
61
+ *"/claude-plugins-official/"*telegram*)
55
62
  TELEGRAM_PIDS="${TELEGRAM_PIDS}${pid}
56
63
  " ;;
57
64
  esac
58
65
  done
66
+ # Runner roots verify by cmdline (their cwd is the SESSION dir, not the
67
+ # plugin dir — `bun run --cwd X` sets X for the child, not for itself).
68
+ RUN_CANDIDATES="$(printf '%s\n' "$SNAPSHOT" | match_telegram_run | awk '{print $1}')"
69
+ for pid in $RUN_CANDIDATES; do
70
+ TELEGRAM_PIDS="${TELEGRAM_PIDS}${pid}
71
+ "
72
+ done
59
73
  fi
60
74
 
61
75
  if [ -n "$TELEGRAM_PIDS" ]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "2.9.3",
3
+ "version": "2.10.1",
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": {
@@ -161,7 +161,7 @@ def fallback_validate():
161
161
  if not isinstance(command, dict):
162
162
  errors.append(f"verification_commands[{idx}] must be an object")
163
163
  continue
164
- unknown_command = sorted(set(command) - {"cmd", "exit_code", "stdout_contains", "stdout_not_contains", "contract_refs"})
164
+ unknown_command = sorted(set(command) - {"cmd", "exit_code", "timeout_sec", "stdout_contains", "stdout_not_contains", "contract_refs"})
165
165
  if unknown_command:
166
166
  errors.append(f"verification_commands[{idx}] has unknown key(s): {', '.join(unknown_command)}")
167
167
  if not isinstance(command.get("cmd"), str) or not command.get("cmd"):
@@ -169,6 +169,9 @@ def fallback_validate():
169
169
  exit_code = command.get("exit_code", 0)
170
170
  if isinstance(exit_code, bool) or not isinstance(exit_code, int):
171
171
  errors.append(f"verification_commands[{idx}].exit_code must be an integer")
172
+ timeout_sec = command.get("timeout_sec", 60)
173
+ if isinstance(timeout_sec, bool) or not isinstance(timeout_sec, int) or not 1 <= timeout_sec <= 600:
174
+ errors.append(f"verification_commands[{idx}].timeout_sec must be an integer from 1 to 600")
172
175
  for key in ("stdout_contains", "stdout_not_contains", "contract_refs"):
173
176
  if key in command and not is_string_list(command[key]):
174
177
  errors.append(f"verification_commands[{idx}].{key} must be an array of non-empty strings")