devlyn-cli 4.3.0 → 4.3.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.
Files changed (62) hide show
  1. package/bin/skill-history.json +3 -0
  2. package/config/skills/_shared/harness-store.py +2 -1
  3. package/config/skills/_shared/task-completion.md +6 -3
  4. package/config/skills/_shared/warm-build-dirs.py +97 -2
  5. package/config/skills/devlyn-ideate/scripts/queue.py +10 -1
  6. package/harness/AGENTS.md +37 -0
  7. package/harness/CLAUDE.md +37 -0
  8. package/native/claude/.claude-plugin/plugin.json +6 -0
  9. package/native/claude/skills/_shared/.ruff.toml +2 -0
  10. package/native/claude/skills/_shared/adapters/claude.md +15 -0
  11. package/native/claude/skills/_shared/adapters/codex.md +14 -0
  12. package/native/claude/skills/_shared/adapters/omp.md +6 -0
  13. package/native/claude/skills/_shared/codex-monitored.sh +263 -0
  14. package/native/claude/skills/_shared/engine-doctor.sh +70 -0
  15. package/native/claude/skills/_shared/engine-preflight.md +29 -0
  16. package/native/claude/skills/_shared/expected-contract.py +242 -0
  17. package/native/claude/skills/_shared/expected.schema.json +120 -0
  18. package/native/claude/skills/_shared/harness-store.py +202 -0
  19. package/native/claude/skills/_shared/invocation-receipt.py +499 -0
  20. package/native/claude/skills/_shared/platform-support.py +395 -0
  21. package/native/claude/skills/_shared/role-config.py +247 -0
  22. package/native/claude/skills/_shared/runtime-principles.md +35 -0
  23. package/native/claude/skills/_shared/task-complete.py +2865 -0
  24. package/native/claude/skills/_shared/task-completion.md +249 -0
  25. package/native/claude/skills/_shared/warm-build-dirs.py +1065 -0
  26. package/native/claude/skills/devlyn-engines/SKILL.md +63 -0
  27. package/native/claude/skills/devlyn-ideate/SKILL.md +132 -0
  28. package/native/claude/skills/devlyn-ideate/references/elicitation.md +29 -0
  29. package/native/claude/skills/devlyn-ideate/references/loop.md +123 -0
  30. package/native/claude/skills/devlyn-ideate/references/package-format.md +128 -0
  31. package/native/claude/skills/devlyn-ideate/references/plan-add.md +12 -0
  32. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +882 -0
  33. package/native/claude/skills/devlyn-ideate/scripts/queue.py +2191 -0
  34. package/native/codex/.agents/plugins/marketplace.json +12 -0
  35. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +6 -0
  36. package/native/codex/plugins/devlyn/skills/_shared/.ruff.toml +2 -0
  37. package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
  38. package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +14 -0
  39. package/native/codex/plugins/devlyn/skills/_shared/adapters/omp.md +6 -0
  40. package/native/codex/plugins/devlyn/skills/_shared/codex-monitored.sh +263 -0
  41. package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +70 -0
  42. package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +29 -0
  43. package/native/codex/plugins/devlyn/skills/_shared/expected-contract.py +242 -0
  44. package/native/codex/plugins/devlyn/skills/_shared/expected.schema.json +120 -0
  45. package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +202 -0
  46. package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +499 -0
  47. package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +395 -0
  48. package/native/codex/plugins/devlyn/skills/_shared/role-config.py +247 -0
  49. package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +35 -0
  50. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +2865 -0
  51. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +249 -0
  52. package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +1065 -0
  53. package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +63 -0
  54. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +132 -0
  55. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/elicitation.md +29 -0
  56. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +123 -0
  57. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +128 -0
  58. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/plan-add.md +12 -0
  59. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +882 -0
  60. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +2191 -0
  61. package/package.json +1 -1
  62. package/release.json +1630 -0
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "devlyn-harness-v4-3-3",
3
+ "plugins": [
4
+ {
5
+ "name": "devlyn",
6
+ "source": {
7
+ "source": "local",
8
+ "path": "./plugins/devlyn"
9
+ }
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "devlyn",
3
+ "version": "4.3.3",
4
+ "description": "Devlyn harness skills",
5
+ "skills": "./skills/"
6
+ }
@@ -0,0 +1,2 @@
1
+ # Vendored devlyn helpers are checked by devlyn's own validation.
2
+ exclude = ["*"]
@@ -0,0 +1,15 @@
1
+ # Claude adapter
2
+
3
+ ## Invocation
4
+
5
+ A Claude executor starts as
6
+ `claude -p "<prompt>" --dangerously-skip-permissions --add-dir "<common git dir>"`,
7
+ where `<common git dir>` is `git rev-parse --path-format=absolute --git-common-dir`:
8
+ a linked task worktree commits there.
9
+
10
+ **Availability probe**: `command -v claude >/dev/null 2>&1`; record
11
+ `claude --version` as evidence. The probe is necessary, not sufficient:
12
+ `claude -p` needs network access to the Anthropic API, so in a network-denying
13
+ sandbox (e.g. Codex CLI's default `workspace-write`) it fails even though the
14
+ binary resolves. A drain blocks when the executor cannot start, and a task
15
+ whose executor exits without a submission waits for the next drain.
@@ -0,0 +1,14 @@
1
+ # Codex adapter
2
+
3
+ ## Invocation
4
+
5
+ A Codex executor runs through `_shared/codex-monitored.sh`, never a raw or piped
6
+ `codex exec`: `bash "$DEVLYN_SHARED_DIR/codex-monitored.sh" <arguments for codex exec> "<prompt>"`.
7
+ The `workspace-write` sandbox keeps `.git` read-only, so an executor that commits adds as
8
+ writable roots the common Git directory (`git rev-parse --path-format=absolute --git-common-dir`)
9
+ plus, in a linked worktree, its own Git directory (`git rev-parse --path-format=absolute --git-dir`),
10
+ listed exactly because the sandbox protects it even under a listed parent. It also
11
+ denies network, which installing a new dependency needs:
12
+ `-s workspace-write -c 'sandbox_workspace_write.writable_roots=["<git dir>","<worktree git dir>"]' -c 'sandbox_workspace_write.network_access=true'`.
13
+ Deliver a multiline prompt as exact file bytes: set `DEVLYN_CODEX_PROMPT_FILE=<file>`
14
+ and pass the sole prompt argument `-`.
@@ -0,0 +1,6 @@
1
+ # oh-my-pi adapter
2
+
3
+ executor: no
4
+
5
+ Engine `omp`, the oh-my-pi CLI.
6
+ Stub: model-call execution and failure handling have not been qualified.
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env bash
2
+ # codex-monitored.sh — run `codex exec` in a monitored shape that keeps the
3
+ # outer claude -p API stream from going silent during long Codex calls.
4
+ #
5
+ # WHY (iter-0009, post iter-0006/0007/0008):
6
+ # • iter-0007 isolation proved a single foreground `codex exec` Bash dispatch
7
+ # can starve the outer API stream of bytes during a 10+ min run; Anthropic's
8
+ # byte-level idle watchdog fires (~300s) and kills the orchestrator.
9
+ # • iter-0008 saw the orchestrator pick `codex exec ... 2>&1 | tail -200` from
10
+ # its own pattern prior — `tail` on a pipe buffers until EOF, suppressing
11
+ # ALL bytes. Same starvation, amplified.
12
+ # • iter-0008 also documented codex 0.124.0 reads stdin as a `<stdin>` block
13
+ # when the prompt is passed as an arg AND stdin is open; without
14
+ # `< /dev/null` the call hangs indefinitely.
15
+ #
16
+ # WHAT THIS WRAPPER DOES:
17
+ # 1. Refuses to run if stdout is a pipe. Piping wrapper output to text tools
18
+ # (tail/head/awk/sed/grep without --line-buffered) re-introduces the
19
+ # iter-0008 starvation mechanism — the downstream tool buffers until EOF
20
+ # and the outer claude -p byte-watchdog never sees bytes. Exits 64 with a
21
+ # clear message so the orchestrator can self-correct on retry.
22
+ # (Round 2 finding #1 fix: shim alone does not defeat `| tail`; the
23
+ # wrapper must reject the pipe shape directly.)
24
+ # 2. Closes stdin unless DEVLYN_CODEX_PROMPT_FILE supplies the sole prompt.
25
+ # 3. Streams codex stdout to OUR stdout line-by-line — the caller reads
26
+ # stdout as the Codex reply, so we MUST NOT swallow it (e.g.
27
+ # `tail -n 200`). codex stderr forwards to OUR stderr.
28
+ # 4. Emits a `[codex-monitored] heartbeat` line every CODEX_MONITORED_HEARTBEAT
29
+ # seconds (default 30s) on STDERR while codex is alive. Heartbeat-on-stderr
30
+ # keeps the orchestrator's combined-output stream non-silent without
31
+ # polluting the codex-reply view of stdout.
32
+ # 5. Forwards SIGTERM/SIGINT from the outer watchdog to the codex child so a
33
+ # timeout actually reaps codex (otherwise process group kill races with
34
+ # backgrounded codex).
35
+ # 6. Preserves codex's exact exit code.
36
+ #
37
+ # USAGE:
38
+ # bash codex-monitored.sh -C <repo> -s read-only "<prompt>"
39
+ # bash codex-monitored.sh resume --last
40
+ # (Args after the script name are passed verbatim to `codex exec`.)
41
+ #
42
+ # ENV OVERRIDES:
43
+ # CODEX_MONITORED_HEARTBEAT — heartbeat interval seconds (default 30).
44
+ # CODEX_MONITORED_TIMEOUT_SEC — optional hard timeout. When >0, kill the
45
+ # codex process group and exit 124.
46
+ # CODEX_BIN — real codex binary path. Default:
47
+ # CODEX_REAL_BIN when set, else `codex`.
48
+ # Set this when the shim has put us first
49
+ # on PATH.
50
+ # CODEX_MONITORED_ALLOW_PIPED — set non-empty to skip the pipe-stdout
51
+ # refusal. Reserved for tests; don't use
52
+ # in skill prompts.
53
+ # DEVLYN_CODEX_PROMPT_FILE — exact binary stdin prompt; requires sole `-`.
54
+
55
+ set -uo pipefail
56
+
57
+ # iter-0019 — solo_claude (L1) arm enforcement (defense in depth alongside
58
+ # scripts/codex-shim/codex). If this env is set, the wrapper refuses to invoke
59
+ # codex at all, regardless of how it was reached. Two enforcement points
60
+ # protect against the case where one is bypassed: the shim catches PATH-based
61
+ # resolution, and this wrapper catches direct-path invocations of
62
+ # codex-monitored.sh that don't go through the shim.
63
+ if [ -n "${CODEX_BLOCKED:-}" ]; then
64
+ printf '[codex-monitored] CODEX_BLOCKED=%s — refusing codex invocation (solo_claude / L1 arm enforcement). args: %s\n' \
65
+ "${CODEX_BLOCKED}" "$*" >&2
66
+ exit 126
67
+ fi
68
+
69
+ HEARTBEAT_SEC="${CODEX_MONITORED_HEARTBEAT:-30}"
70
+ TIMEOUT_SEC="${CODEX_MONITORED_TIMEOUT_SEC:-0}"
71
+ CODEX_BIN="${CODEX_BIN:-${CODEX_REAL_BIN:-codex}}"
72
+ START=$(date +%s)
73
+ TIMEOUT_FLAG=""
74
+ CODEX_ARGS=("$@")
75
+
76
+ for codex_arg in "${CODEX_ARGS[@]}"; do
77
+ case "$codex_arg" in
78
+ --dangerously-bypass-approvals-and-sandbox|--yolo)
79
+ printf '[codex-monitored] error: forbidden Codex bypass flag: %s\n' "$codex_arg" >&2
80
+ exit 64
81
+ ;;
82
+ danger-full-access|-s=danger-full-access|--sandbox=danger-full-access)
83
+ printf '[codex-monitored] error: forbidden Codex sandbox: danger-full-access\n' >&2
84
+ exit 64
85
+ ;;
86
+ esac
87
+ done
88
+
89
+ require_nonnegative_int() {
90
+ local name="$1"
91
+ local value="$2"
92
+ case "$value" in
93
+ ''|*[!0-9]*)
94
+ printf '[codex-monitored] error: %s must be a non-negative integer (got %s)\n' \
95
+ "$name" "$value" >&2
96
+ exit 64
97
+ ;;
98
+ esac
99
+ }
100
+
101
+ require_positive_int() {
102
+ local name="$1"
103
+ local value="$2"
104
+ require_nonnegative_int "$name" "$value"
105
+ if [ "$value" -le 0 ]; then
106
+ printf '[codex-monitored] error: %s must be > 0 (got %s)\n' \
107
+ "$name" "$value" >&2
108
+ exit 64
109
+ fi
110
+ }
111
+
112
+ require_positive_int CODEX_MONITORED_HEARTBEAT "$HEARTBEAT_SEC"
113
+ require_nonnegative_int CODEX_MONITORED_TIMEOUT_SEC "$TIMEOUT_SEC"
114
+
115
+ # --- Pipe-stdout refusal (iter-0009 R2 finding #1) -------------------------
116
+ # `[ -p /dev/stdout ]` is the POSIX test for "is fd 1 a FIFO/pipe". Verified
117
+ # correct on macOS via lsof: distinguishes piped (`| cat`) from redirected
118
+ # (`> file`) and from claude-bash-tool capture (regular file). Without this
119
+ # refusal, `bash WRAPPER ... 2>&1 | tail -200` would buffer wrapper output —
120
+ # including the heartbeat on stderr after `2>&1` — until EOF, reproducing
121
+ # the iter-0008 byte-watchdog kill.
122
+ if [ -z "${CODEX_MONITORED_ALLOW_PIPED:-}" ] && [ -p /dev/stdout ]; then
123
+ cat >&2 <<'EOF'
124
+ [codex-monitored] error: stdout is a pipe.
125
+
126
+ Piping the wrapper to tail/head/awk/sed/grep buffers wrapper output until EOF,
127
+ which starves the outer claude -p byte-watchdog (iter-0008 starvation mechanism)
128
+ and kills the run after ~300s with empty transcript.
129
+
130
+ Fix: invoke the wrapper directly so the bash tool captures its stdout. The
131
+ wrapper streams full Codex output and emits a heartbeat on stderr; you do NOT
132
+ need to truncate.
133
+
134
+ WRONG: bash codex-monitored.sh ... 2>&1 | tail -200
135
+ RIGHT: bash codex-monitored.sh ...
136
+
137
+ If you absolutely must filter, use a line-buffered tool (e.g. `grep --line-buffered`)
138
+ and set CODEX_MONITORED_ALLOW_PIPED=1 in the wrapper's environment.
139
+ EOF
140
+ exit 64
141
+ fi
142
+
143
+ # --- Heartbeat + signal forwarding ----------------------------------------
144
+ heartbeat_loop() {
145
+ local pid="$1"
146
+ while kill -0 "$pid" 2>/dev/null; do
147
+ sleep "$HEARTBEAT_SEC"
148
+ if kill -0 "$pid" 2>/dev/null; then
149
+ local elapsed=$(( $(date +%s) - START ))
150
+ printf '[codex-monitored] heartbeat: elapsed=%ds\n' "$elapsed" >&2
151
+ fi
152
+ done
153
+ }
154
+
155
+ timeout_loop() {
156
+ local pid="$1"
157
+ local seconds="$2"
158
+ local flag="$3"
159
+ [ "$seconds" -gt 0 ] || return 0
160
+ sleep "$seconds"
161
+ if kill -0 "$pid" 2>/dev/null; then
162
+ : > "$flag"
163
+ printf '[codex-monitored] timeout: elapsed=%ds limit=%ds\n' \
164
+ "$(( $(date +%s) - START ))" "$seconds" >&2
165
+ kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
166
+ sleep 5
167
+ kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
168
+ fi
169
+ }
170
+
171
+ terminate_process_group() {
172
+ local pgid="$1"
173
+ local reason="$2"
174
+ if ! kill -0 -- "-$pgid" 2>/dev/null; then
175
+ return 0
176
+ fi
177
+ printf '[codex-monitored] reap: reason=%s pgid=%s\n' "$reason" "$pgid" >&2
178
+ kill -TERM -- "-$pgid" 2>/dev/null || true
179
+ local i
180
+ for i in 1 2 3 4 5; do
181
+ sleep 1
182
+ if ! kill -0 -- "-$pgid" 2>/dev/null; then
183
+ return 0
184
+ fi
185
+ done
186
+ kill -KILL -- "-$pgid" 2>/dev/null || true
187
+ }
188
+
189
+ terminate_monitor_group() {
190
+ local pid="$1"
191
+ kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
192
+ kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
193
+ wait "$pid" 2>/dev/null || true
194
+ }
195
+
196
+ forward_signal() {
197
+ local sig="$1"
198
+ if [ -n "${CODEX_PID:-}" ] && kill -0 "$CODEX_PID" 2>/dev/null; then
199
+ kill -"$sig" -- "-$CODEX_PID" 2>/dev/null || kill -"$sig" "$CODEX_PID" 2>/dev/null || true
200
+ fi
201
+ if [ -n "${HB_PID:-}" ]; then
202
+ terminate_monitor_group "$HB_PID"
203
+ HB_PID=""
204
+ fi
205
+ if [ -n "${WATCHDOG_PID:-}" ]; then
206
+ terminate_monitor_group "$WATCHDOG_PID"
207
+ WATCHDOG_PID=""
208
+ fi
209
+ }
210
+
211
+ cleanup() {
212
+ forward_signal TERM
213
+ [ -z "$TIMEOUT_FLAG" ] || rm -f "$TIMEOUT_FLAG"
214
+ }
215
+
216
+ trap 'forward_signal TERM; exit 143' TERM
217
+ trap 'forward_signal INT; exit 130' INT
218
+ trap cleanup EXIT
219
+
220
+ printf '[codex-monitored] start: ts=%s heartbeat=%ds timeout=%ss bin=%s\n' \
221
+ "$(date -u +%FT%TZ)" "$HEARTBEAT_SEC" "$TIMEOUT_SEC" "$CODEX_BIN" >&2
222
+
223
+ RECEIPT_HELPER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/invocation-receipt.py"
224
+ # Native Python owns Windows PIDs and the timeout tree; MSYS PIDs are different.
225
+ if [ "${OS:-}" = Windows_NT ]; then
226
+ exec python3 "$RECEIPT_HELPER" dispatch --binary "$CODEX_BIN" \
227
+ --timeout "$TIMEOUT_SEC" --heartbeat "$HEARTBEAT_SEC" -- "${CODEX_ARGS[@]}" < /dev/null
228
+ fi
229
+
230
+ # The dispatcher snapshots file input, seals actual argv, then execs in this group.
231
+ set -m
232
+ python3 "$RECEIPT_HELPER" dispatch --binary "$CODEX_BIN" \
233
+ --timeout "$TIMEOUT_SEC" --heartbeat "$HEARTBEAT_SEC" -- "${CODEX_ARGS[@]}" < /dev/null &
234
+ CODEX_PID=$!
235
+ printf '[codex-monitored] codex pid=%d\n' "$CODEX_PID" >&2
236
+
237
+ heartbeat_loop "$CODEX_PID" &
238
+ HB_PID=$!
239
+
240
+ if [ "$TIMEOUT_SEC" -gt 0 ]; then
241
+ TIMEOUT_FLAG=$(mktemp "${TMPDIR:-/tmp}/codex-monitored-timeout.XXXXXX")
242
+ rm -f "$TIMEOUT_FLAG"
243
+ timeout_loop "$CODEX_PID" "$TIMEOUT_SEC" "$TIMEOUT_FLAG" &
244
+ WATCHDOG_PID=$!
245
+ fi
246
+ set +m
247
+
248
+ wait "$CODEX_PID"
249
+ EXIT=$?
250
+ terminate_process_group "$CODEX_PID" "post-exit-descendants"
251
+ CODEX_PID=""
252
+ forward_signal TERM
253
+ if [ -n "$TIMEOUT_FLAG" ] && [ -f "$TIMEOUT_FLAG" ]; then
254
+ EXIT=124
255
+ fi
256
+
257
+ if [ -n "${DEVLYN_CODEX_PROMPT_FILE:-}" ]; then
258
+ python3 "$RECEIPT_HELPER" complete-dispatch --exit-code "$EXIT" || exit 64
259
+ fi
260
+
261
+ printf '[codex-monitored] codex exited: code=%d elapsed=%ds\n' \
262
+ "$EXIT" $(( $(date +%s) - START )) >&2
263
+ exit "$EXIT"
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ # engine-doctor.sh — read-only detection for /devlyn-engines' no-arg output.
3
+ #
4
+ # WHY (iter-0050): users need to see what's actually on the machine, not
5
+ # just what's pinned. This script never writes .devlyn/engines.json, never
6
+ # installs anything, never changes pin validation — it only reports.
7
+ #
8
+ # macOS-safe bash 3.2: no associative arrays, no mapfile/readarray.
9
+ set -uo pipefail
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
12
+ ADAPTERS_DIR="$SCRIPT_DIR/adapters"
13
+
14
+ TARGETS=(claude codex omp pi)
15
+ KINDS=(cli-engine cli-engine cli-engine orchestrator-only)
16
+ BINARIES=(claude codex omp "")
17
+ INSTALL_HINTS=(
18
+ "see https://docs.anthropic.com/en/docs/claude-code"
19
+ "npm install -g @openai/codex"
20
+ "brew install can1357/tap/omp"
21
+ ""
22
+ )
23
+
24
+ check_binary() {
25
+ # $1 = binary name, "" means no known binary to probe
26
+ [ -n "$1" ] || { printf 'unknown'; return; }
27
+ if command -v "$1" >/dev/null 2>&1; then printf 'yes'; else printf 'no'; fi
28
+ }
29
+
30
+ check_adapter() {
31
+ # $1 = target name
32
+ if [ -f "$ADAPTERS_DIR/$1.md" ]; then printf 'yes'; else printf 'no'; fi
33
+ }
34
+
35
+ check_executor() {
36
+ # $1 = adapter file path. An adapter may declare the fixed ASCII field
37
+ # `executor: no` under `## Role eligibility`; otherwise it can execute.
38
+ [ -f "$1" ] || { printf 'n/a'; return; }
39
+ if grep -q '^executor: no' "$1" 2>/dev/null; then printf 'no'; else printf 'yes'; fi
40
+ }
41
+
42
+ printf '%-8s %-17s %-8s %-8s %-9s %s\n' \
43
+ 'target' 'kind' 'binary' 'adapter' 'executor' 'note'
44
+
45
+ for i in "${!TARGETS[@]}"; do
46
+ target="${TARGETS[$i]}"
47
+ kind="${KINDS[$i]}"
48
+ binary="$(check_binary "${BINARIES[$i]}")"
49
+ adapter="$(check_adapter "$target")"
50
+ executor="$(check_executor "$ADAPTERS_DIR/$target.md")"
51
+
52
+ note='-'
53
+ case "$kind" in
54
+ cli-engine)
55
+ if [ "$binary" = 'no' ]; then
56
+ note="not installed; ${INSTALL_HINTS[$i]}"
57
+ elif [ "$adapter" = 'no' ]; then
58
+ note="binary present, no adapter — reinstall devlyn-cli, whose releases ship the adapters"
59
+ elif [ "$executor" = 'no' ]; then
60
+ note='adapter declares executor: no'
61
+ fi
62
+ ;;
63
+ orchestrator-only)
64
+ note='informational only; not a routable engine — no verified CLI binary or adapter'
65
+ ;;
66
+ esac
67
+
68
+ printf '%-8s %-17s %-8s %-8s %-9s %s\n' \
69
+ "$target" "$kind" "$binary" "$adapter" "$executor" "$note"
70
+ done
@@ -0,0 +1,29 @@
1
+ # Shared — Executor Pre-flight
2
+
3
+ Used by `/devlyn-ideate` and `/devlyn-engines`: one rule for which engine executes implementation work and what happens when it is missing.
4
+
5
+ ## Selection
6
+
7
+ The executor is the `executor` pin in `.devlyn/engines.json` at the checkout root (set with `/devlyn-engines executor <name>`), else the CLI the user opened. A linked worktree or subdirectory has no pin unless one is set there. Pi and Grok have no executor adapter, so a drain from them needs a claude, codex or omp pin. Keys earlier releases wrote for retired pipeline roles (`pair_judge_priority`, `roles`) stay as written and select nothing: a worker profile never becomes the executor.
8
+
9
+ Before dispatching to the executor, run:
10
+
11
+ ```sh
12
+ python3 "$DEVLYN_SHARED_DIR/role-config.py" --workdir <project> --default-engine <this CLI> --select
13
+ ```
14
+
15
+ A pin is a promise. When the pinned engine's CLI is unavailable, the command exits 1 with `BLOCKED:<engine>-unavailable`: stop, preserve the evidence, and show the setup guidance — install and authenticate that CLI, verify `<engine> --version`, rerun. Never substitute another engine. A malformed file, or a name without an executor-eligible adapter, is `BLOCKED:invalid-engine-config`. CLI presence is not authentication, and status never launches a model.
16
+
17
+ ## Engines
18
+
19
+ An engine is pinnable when `_shared/adapters/<name>.md` exists and does not declare `executor: no` under `## Role eligibility`. Adapters ship with devlyn-cli releases, and a reinstall replaces `_shared`. An adapter's `## Invocation` says how to start it. On native Windows, use native Node/npm and Python (`python3` on PATH), with Git for Windows Bash for the shipped shell wrapper.
20
+
21
+ ## Reporting a blocked engine
22
+
23
+ The final report names the selected engine, the verdict and the setup steps; it never reports a downgraded success:
24
+
25
+ ```
26
+ Engine: codex (pinned executor)
27
+ Verdict: BLOCKED:codex-unavailable
28
+ Setup: install/configure Codex CLI; run the current Codex auth/login flow; verify `codex --version`; rerun.
29
+ ```
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env python3
2
+ """Strict JSON and `spec.expected.json` contract validation for the ideate loop's package check and acceptance runner."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import unittest
8
+
9
+
10
+ def reject_json_constant(token: str) -> None:
11
+ raise ValueError(f"invalid JSON numeric constant: {token}")
12
+
13
+
14
+ def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
15
+ result = {}
16
+ for key, value in pairs:
17
+ if key in result:
18
+ raise ValueError(f"duplicate JSON key: {key}")
19
+ result[key] = value
20
+ return result
21
+
22
+
23
+ def loads_strict_json(text: str):
24
+ return json.loads(
25
+ text,
26
+ parse_constant=reject_json_constant,
27
+ object_pairs_hook=reject_duplicate_keys,
28
+ )
29
+
30
+
31
+ EXPECTED_TOP_LEVEL_KEYS = {
32
+ "verification_commands",
33
+ "forbidden_patterns",
34
+ "required_files",
35
+ "forbidden_files",
36
+ "max_deps_added",
37
+ "pure_design",
38
+ }
39
+ EXPECTED_VERIFICATION_COMMAND_KEYS = {
40
+ "cmd",
41
+ "argv",
42
+ "exit_code",
43
+ "timeout_sec",
44
+ "stdout_contains",
45
+ "stdout_not_contains",
46
+ "contract_refs",
47
+ }
48
+ DEFAULT_TIMEOUT_SEC = 60
49
+
50
+
51
+ def verification_timeout_sec(command: dict) -> int:
52
+ return command.get("timeout_sec", DEFAULT_TIMEOUT_SEC)
53
+
54
+
55
+ def validate_command(command: object, label: str) -> str | None:
56
+ """Validate one command; `argv` and `cmd` are mutually exclusive."""
57
+ if not isinstance(command, dict):
58
+ return f"{label} must be an object"
59
+ if "argv" in command:
60
+ if "cmd" in command:
61
+ return f"{label} requires exactly one of cmd or argv"
62
+ value = command["argv"]
63
+ if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value) or not value[0]:
64
+ return f"{label}.argv must be a string array with non-empty argv[0]"
65
+ else:
66
+ cmd = command.get("cmd")
67
+ if not isinstance(cmd, str) or not cmd.strip():
68
+ return f"{label}.cmd must be a non-empty string"
69
+ ec = command.get("exit_code", 0)
70
+ if isinstance(ec, bool) or not isinstance(ec, int):
71
+ return f"{label}.exit_code must be int (not bool)"
72
+ timeout_sec = command.get("timeout_sec", DEFAULT_TIMEOUT_SEC)
73
+ if isinstance(timeout_sec, bool) or not isinstance(timeout_sec, int) or not 1 <= timeout_sec <= 600:
74
+ return f"{label}.timeout_sec must be int from 1 to 600 (not bool)"
75
+ for k in ("stdout_contains", "stdout_not_contains"):
76
+ v = command.get(k, [])
77
+ if not isinstance(v, list) or not all(isinstance(s, str) and s for s in v):
78
+ return f"{label}.{k} must be a list of non-empty strings"
79
+ return None
80
+
81
+
82
+ def validate_shape(data) -> str | None:
83
+ """Return None if `data` has a non-empty, well-formed `verification_commands`
84
+ list; else a human-readable error string.
85
+
86
+ Each object requires exactly one of a non-empty string `cmd` or a
87
+ string-array `argv`; `exit_code` defaults to 0 and must be a
88
+ non-bool int; `timeout_sec` defaults to DEFAULT_TIMEOUT_SEC and must be a
89
+ non-bool int from 1 through 600; `stdout_contains` and `stdout_not_contains`
90
+ default to empty lists of non-empty strings. Bool is rejected explicitly
91
+ because Python's `bool` subclasses `int`.
92
+ """
93
+ if not isinstance(data, dict):
94
+ return "top-level must be a JSON object"
95
+ cmds = data.get("verification_commands")
96
+ if not isinstance(cmds, list):
97
+ return "verification_commands must be a list"
98
+ if not cmds:
99
+ return "verification_commands must contain at least one entry"
100
+ for i, c in enumerate(cmds):
101
+ err = validate_command(c, f"verification_commands[{i}]")
102
+ if err:
103
+ return err
104
+ return None
105
+
106
+
107
+ def validate_string_list(data: object, key: str) -> str | None:
108
+ value = data.get(key, []) if isinstance(data, dict) else None
109
+ if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
110
+ return f"{key} must be a list of non-empty strings"
111
+ return None
112
+
113
+
114
+ def validate_expected_shape(data) -> str | None:
115
+ """Return None if shape matches the sibling spec.expected.json schema.
116
+
117
+ Keep this dependency-free: it mirrors `_shared/expected.schema.json` enough
118
+ to catch malformed contracts before a consumer executes them.
119
+ """
120
+ if not isinstance(data, dict):
121
+ return "top-level must be a JSON object"
122
+ unknown = sorted(set(data) - EXPECTED_TOP_LEVEL_KEYS)
123
+ if unknown:
124
+ return f"unknown top-level key(s): {', '.join(unknown)}"
125
+ if "verification_commands" in data:
126
+ commands = data["verification_commands"]
127
+ if not isinstance(commands, list):
128
+ return "verification_commands must be a list"
129
+ if commands:
130
+ err = validate_shape({"verification_commands": commands})
131
+ if err:
132
+ return err
133
+ for i, command in enumerate(commands):
134
+ unknown_command_keys = sorted(set(command) - EXPECTED_VERIFICATION_COMMAND_KEYS)
135
+ if unknown_command_keys:
136
+ return (
137
+ f"verification_commands[{i}] unknown key(s): "
138
+ f"{', '.join(unknown_command_keys)}"
139
+ )
140
+ contract_refs = command.get("contract_refs", [])
141
+ if not isinstance(contract_refs, list) or not all(
142
+ isinstance(item, str) and item for item in contract_refs
143
+ ):
144
+ return f"verification_commands[{i}].contract_refs must be a list of non-empty strings"
145
+ for key in ("required_files", "forbidden_files"):
146
+ err = validate_string_list(data, key)
147
+ if err:
148
+ return err
149
+ max_deps = data.get("max_deps_added", 0)
150
+ if isinstance(max_deps, bool) or not isinstance(max_deps, int) or max_deps < 0:
151
+ return "max_deps_added must be a non-negative integer"
152
+ if "pure_design" in data and not isinstance(data["pure_design"], bool):
153
+ return "pure_design must be a boolean"
154
+ patterns = data.get("forbidden_patterns", [])
155
+ if not isinstance(patterns, list):
156
+ return "forbidden_patterns must be a list"
157
+ for i, pattern in enumerate(patterns):
158
+ if not isinstance(pattern, dict):
159
+ return f"forbidden_patterns[{i}] must be an object"
160
+ unknown_pattern_keys = sorted(set(pattern) - {"pattern", "description", "files", "severity"})
161
+ if unknown_pattern_keys:
162
+ return (
163
+ f"forbidden_patterns[{i}] unknown key(s): "
164
+ f"{', '.join(unknown_pattern_keys)}"
165
+ )
166
+ for key in ("pattern", "description", "severity"):
167
+ value = pattern.get(key)
168
+ if not isinstance(value, str) or not value:
169
+ return f"forbidden_patterns[{i}].{key} must be a non-empty string"
170
+ if pattern["severity"] not in {"disqualifier", "warning"}:
171
+ return f"forbidden_patterns[{i}].severity must be disqualifier or warning"
172
+ files = pattern.get("files", [])
173
+ if not isinstance(files, list) or not all(isinstance(item, str) and item for item in files):
174
+ return f"forbidden_patterns[{i}].files must be a list of non-empty strings"
175
+ return None
176
+
177
+
178
+ def slice_diff_to_files(diff_text: str, files: list[str]) -> str:
179
+ if not files:
180
+ return diff_text
181
+ out: list[str] = []
182
+ keep = False
183
+ for line in diff_text.splitlines(keepends=True):
184
+ if line.startswith("diff --git "):
185
+ keep = any(path in line for path in files)
186
+ if keep:
187
+ out.append(line)
188
+ return "".join(out)
189
+
190
+
191
+ class ContractTests(unittest.TestCase):
192
+ def test_strict_json(self):
193
+ for text, message in (('{"a":1,"a":2}', "duplicate JSON key"), ('{"a":NaN}', "invalid JSON numeric constant")):
194
+ with self.assertRaisesRegex(ValueError, message):
195
+ loads_strict_json(text)
196
+
197
+ def test_cmd_and_argv_are_mutually_exclusive(self):
198
+ ok = {"verification_commands": [{"argv": ["python3", "-c", "pass"], "contract_refs": ["R1"]}, {"cmd": "true"}]}
199
+ self.assertIsNone(validate_expected_shape(ok))
200
+ for command, message in (
201
+ ({"cmd": "true", "argv": ["true"]}, "exactly one of cmd or argv"),
202
+ ({"argv": []}, "argv must be a string array"),
203
+ ({"argv": ["", "x"]}, "argv must be a string array"),
204
+ ({"argv": ["x", 1]}, "argv must be a string array"),
205
+ ({}, "cmd must be a non-empty string"),
206
+ ):
207
+ self.assertIn(message, validate_expected_shape({"verification_commands": [command]}))
208
+
209
+ def test_command_fields(self):
210
+ for command, message in (
211
+ ({"cmd": "x", "exit_code": True}, "exit_code must be int"),
212
+ ({"cmd": "x", "timeout_sec": 601}, "timeout_sec must be int from 1 to 600"),
213
+ ({"cmd": "x", "stdout_contains": "x"}, "stdout_contains must be a list of non-empty strings"),
214
+ ({"cmd": "x", "stdout_contains": [""]}, "stdout_contains must be a list of non-empty strings"),
215
+ ({"cmd": "x", "stdout_not_contains": [""]}, "stdout_not_contains must be a list of non-empty strings"),
216
+ ({"cmd": "x", "contract_refs": [""]}, "contract_refs must be a list of non-empty strings"),
217
+ ({"cmd": "x", "extra": 1}, "unknown key(s): extra"),
218
+ ):
219
+ self.assertIn(message, validate_expected_shape({"verification_commands": [command]}))
220
+ self.assertEqual(validate_expected_shape([]), "top-level must be a JSON object")
221
+ self.assertIn("unknown top-level key(s): process_evidence", validate_expected_shape({"process_evidence": []}))
222
+
223
+ def test_diff_helpers(self):
224
+ diff = ('diff --git a/package.json b/package.json\n--- a/package.json\n+++ b/package.json\n@@ -1,3 +1,5 @@\n'
225
+ ' {\n "dependencies": {\n+ "left-pad": "1.0.0",\n+ "is-odd": "3.0.0"\n }\n }\n'
226
+ 'diff --git a/other b/other\n+x\n')
227
+ self.assertNotIn("other", slice_diff_to_files(diff, ["package.json"]))
228
+ self.assertEqual(slice_diff_to_files(diff, []), diff)
229
+
230
+
231
+ def main() -> int:
232
+ if sys.argv[1:] != ["--self-test"]:
233
+ print("usage: expected-contract.py --self-test", file=sys.stderr)
234
+ return 2
235
+ result = unittest.TextTestRunner(verbosity=1).run(unittest.defaultTestLoader.loadTestsFromTestCase(ContractTests))
236
+ if result.wasSuccessful():
237
+ print(f"expected-contract self-test: PASS ({result.testsRun} tests)")
238
+ return 0 if result.wasSuccessful() else 1
239
+
240
+
241
+ if __name__ == "__main__":
242
+ sys.exit(main())