devlyn-cli 4.2.26 → 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.
- package/bin/devlyn.js +510 -0
- package/bin/engines.js +20 -15
- package/bin/harness-activation.js +1 -2
- package/bin/release.js +16 -3
- package/bin/skill-history.json +5 -0
- package/config/skills/_shared/harness-store.py +202 -0
- package/config/skills/_shared/task-completion.md +6 -3
- package/config/skills/_shared/warm-build-dirs.py +97 -2
- package/config/skills/devlyn-ideate/SKILL.md +5 -1
- package/config/skills/devlyn-ideate/references/loop.md +12 -2
- package/config/skills/devlyn-ideate/references/plan-add.md +12 -0
- package/config/skills/devlyn-ideate/scripts/acceptance.py +5 -0
- package/config/skills/devlyn-ideate/scripts/queue.py +165 -15
- package/harness/AGENTS.md +37 -0
- package/harness/CLAUDE.md +37 -0
- package/native/claude/.claude-plugin/plugin.json +6 -0
- package/native/claude/skills/_shared/.ruff.toml +2 -0
- package/native/claude/skills/_shared/adapters/claude.md +15 -0
- package/native/claude/skills/_shared/adapters/codex.md +14 -0
- package/native/claude/skills/_shared/adapters/omp.md +6 -0
- package/native/claude/skills/_shared/codex-monitored.sh +263 -0
- package/native/claude/skills/_shared/engine-doctor.sh +70 -0
- package/native/claude/skills/_shared/engine-preflight.md +29 -0
- package/native/claude/skills/_shared/expected-contract.py +242 -0
- package/native/claude/skills/_shared/expected.schema.json +120 -0
- package/native/claude/skills/_shared/harness-store.py +202 -0
- package/native/claude/skills/_shared/invocation-receipt.py +499 -0
- package/native/claude/skills/_shared/platform-support.py +395 -0
- package/native/claude/skills/_shared/role-config.py +247 -0
- package/native/claude/skills/_shared/runtime-principles.md +35 -0
- package/native/claude/skills/_shared/task-complete.py +2865 -0
- package/native/claude/skills/_shared/task-completion.md +249 -0
- package/native/claude/skills/_shared/warm-build-dirs.py +1065 -0
- package/native/claude/skills/devlyn-engines/SKILL.md +63 -0
- package/native/claude/skills/devlyn-ideate/SKILL.md +132 -0
- package/native/claude/skills/devlyn-ideate/references/elicitation.md +29 -0
- package/native/claude/skills/devlyn-ideate/references/loop.md +123 -0
- package/native/claude/skills/devlyn-ideate/references/package-format.md +128 -0
- package/native/claude/skills/devlyn-ideate/references/plan-add.md +12 -0
- package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +882 -0
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +2191 -0
- package/native/codex/.agents/plugins/marketplace.json +12 -0
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +6 -0
- package/native/codex/plugins/devlyn/skills/_shared/.ruff.toml +2 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +14 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/omp.md +6 -0
- package/native/codex/plugins/devlyn/skills/_shared/codex-monitored.sh +263 -0
- package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +70 -0
- package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +29 -0
- package/native/codex/plugins/devlyn/skills/_shared/expected-contract.py +242 -0
- package/native/codex/plugins/devlyn/skills/_shared/expected.schema.json +120 -0
- package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +202 -0
- package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +499 -0
- package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +395 -0
- package/native/codex/plugins/devlyn/skills/_shared/role-config.py +247 -0
- package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +35 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +2865 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +249 -0
- package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +1065 -0
- package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +63 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +132 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/elicitation.md +29 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +123 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +128 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/plan-add.md +12 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +882 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +2191 -0
- package/package.json +3 -2
- package/release.json +1630 -0
|
@@ -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,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())
|