okstra 0.125.6 → 0.127.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +2 -1
- package/docs/project-structure-overview.md +2 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-antigravity-exec.sh +8 -2
- package/runtime/bin/okstra-claude-exec.sh +8 -2
- package/runtime/bin/okstra-codex-exec.sh +65 -8
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/convergence.md +4 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +24 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +3 -0
- package/runtime/python/okstra_ctl/design_surfaces.py +30 -14
- package/runtime/python/okstra_ctl/render_final_report.py +71 -5
- package/runtime/python/okstra_ctl/run.py +3 -1
- package/runtime/python/okstra_ctl/schema_excerpt.py +17 -1
- package/runtime/python/okstra_ctl/worker_heartbeat.py +50 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +140 -0
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +20 -0
- package/runtime/templates/reports/final-report.template.md +9 -9
- package/runtime/templates/worker-prompt-preamble.md +1 -0
- package/runtime/validators/forbidden_actions.py +43 -4
- package/runtime/validators/validate-implementation-plan-stages.py +6 -4
- package/runtime/validators/validate-report-views.py +4 -4
- package/runtime/validators/validate-run.py +7 -6
- package/runtime/validators/validate-schedule.py +6 -4
- package/runtime/validators/validate_fanout.py +4 -3
- package/runtime/validators/validate_improvement_report.py +6 -4
- package/runtime/validators/validate_session_conformance.py +16 -7
- package/src/cli-registry.mjs +7 -0
- package/src/commands/inspect/worker-liveness.mjs +31 -0
- package/src/commands/lifecycle/preflight.mjs +25 -2
- package/src/lib/helper-scripts.mjs +23 -0
- package/src/lib/python-helper.mjs +1 -1
package/docs/architecture.md
CHANGED
|
@@ -732,7 +732,8 @@ Errors that occur while workers (Claude/Codex/Antigravity worker, Report writer,
|
|
|
732
732
|
|
|
733
733
|
### Live-log mirror (codex / antigravity wrapper)
|
|
734
734
|
|
|
735
|
-
- On every dispatch, `scripts/okstra-codex-exec.sh` and `scripts/okstra-antigravity-exec.sh` create a `<prompt>.log` sidecar next to the prompt path and mirror stdout there (
|
|
735
|
+
- On every dispatch, `scripts/okstra-codex-exec.sh` and `scripts/okstra-antigravity-exec.sh` create a `<prompt>.log` sidecar next to the prompt path and mirror stdout there through a named FIFO (which keeps the CLI a single addressable PID for the idle watchdog). stderr is appended to the same file, preserving the subagent stderr-capture contract, and the file is truncated on each dispatch. This solves the problem where a calling subagent polls `BashOutput` every 60 seconds, leaving users unable to detect a stalled state during long-running work such as large-codebase scans in analysis or cargo / pytest in implementation.
|
|
736
|
+
- **Per-block cap on the codex log copy** (`okstra_log_mirror` in `scripts/okstra-codex-exec.sh`): workers read their required inputs end-to-end per the Worker Preamble's *Reading rules*, so a single report read can dump 170KB+ into the log and observed sidecars reach 8MB. The mirror keeps the first `log_block_line_cap` (120) lines of each output block and replaces the remainder with a `[okstra log-mirror] N line(s) elided` marker, draining every 500 elided lines so the idle watchdog keeps seeing writes. **Only the log copy is capped** — the stdout passthrough stays byte-identical, so the dispatching subagent's `BashOutput` and Phase 5 synthesis are unaffected (`tests-js/codex-log-mirror.test.mjs` asserts that byte-identity). The marker set is codex-specific; the claude wrapper emits `--output-format=stream-json` and does not share this filter.
|
|
736
737
|
- When tmux is reachable in the lead environment, the wrapper automatically splits a sibling pane and runs `tail -F <log-path>`. The trace-pane title appends `-tail` to the caller (worker) pane title: `<cli>-<role>-<pid>-tail` (for example, `codex-worker-93421-tail`). At the same time, the caller (worker) pane title is set to `<cli>-<role>-<pid>`. `<pid>` is the wrapper's own PID, so multiple workers with the same role spawned concurrently remain distinguishable, and operators can visually map `<caller> ↔ <caller>-tail`. **Caller-pane resolution**—because the Claude Code Bash tool now removes both `$TMUX` and `$TMUX_PANE` from the environment, the wrapper does not depend on environment variables. It (1) derives `<RUN_DIR>` as `dirname(dirname(prompt_path))` from the prompt path (paths.py SSOT), and (2) reads `<RUN_DIR>/state/lead-pane.id`, written once by the lead in its foreground pane, as the split anchor. This remains reliable for background dispatches, unlike active-pane guessing, even if the user changes panes. If the file is absent or the pane is stale, it falls back to `tmux display-message -p '#{pane_id}'` (the active pane). The trace split explicitly anchors to that caller pane with `-t`. The role is the wrapper's fifth optional positional argument and defaults to `worker`. The caller pane title is captured and restored by an EXIT trap, preventing stale titles across dispatches. Focus returns to the caller pane, and the trace pane remains after CLI exit so its scrollback is available. All paths silently degrade when tmux is unreachable, splitting fails, or tmux is outdated.
|
|
737
738
|
- **Run-scoped tagging for cleanup**: A trace pane's `tail -F` is a child of the tmux shell and survives Claude's exit. The wrapper tags each spawned pane with `tmux set-option -p @okstra_trace_run=<RUN_DIR>`, and `okstra-trace-cleanup.sh` discovers panes server-wide from that tag via `tmux list-panes -a` and runs `tmux kill-pane`. It requires neither tmux environment variables nor a pane-ID registry. Because the tag is run-scoped, it does not kill trace panes from other simultaneous okstra runs. Cleanup has two entry forms: the lead invokes it with `--run-dir <RUN_DIR>` to clean traces and worker-agent panes for that run, or the `hooks.SessionEnd` entry in `templates/reports/settings.template.json` invokes it with `--reap` to clean all trace panes tagged below `$CLAUDE_PROJECT_DIR/.okstra/` when no single run directory exists at session end. Missing tmux and stale pane IDs silently degrade.
|
|
738
739
|
- **Automatic cleanup on phase transitions, including worker-agent panes**: `okstra-trace-cleanup.sh --run-dir <RUN_DIR>` closes not only tagged trace panes but also worker-agent panes occupied by dispatched subagents. These harness-owned panes cannot be tagged, so the script identifies them within the lead session (`tmux list-panes -s -t <lead-pane>`) through a title allowlist: `claude-worker` / `codex-worker` / `antigravity-worker` / `report-writer-worker`. Implementation role titles such as `claude-executor` / `codex-verifier` / `agy-executor-tail`, and FleetView teammate prefixes `✳ ` / `⠂ `, are also treated as okstra panes. Session scoping and exclusion of the lead's own pane are determined by `<RUN_DIR>/state/lead-pane.id`; the lead pane is never killed even if its title matches. Immediately before dispatching workers for a new phase (before the `PROGRESS: phase-5.5-convergence` / `phase-6-synthesis` marker), the lead calls this script with `--run-dir` to clean previous-phase panes without prompting.
|
|
@@ -169,6 +169,7 @@ Runtime/install asset changes follow this checklist:
|
|
|
169
169
|
| `resolve-task-key` | `src/commands/inspect/resolve-task-key.mjs` | Resolve a bare task-id to candidate task-keys from the project catalog |
|
|
170
170
|
| `set-work-status` | `src/commands/inspect/set-work-status.mjs` | Set a task's user-managed `workStatus` in task-manifest.json (Python: `okstra_ctl.set_work_status`) |
|
|
171
171
|
| `time-report`, `log-report`, `error-report`, `error-zip` | `src/commands/inspect/*.mjs` | Read-side task runtime, wrapper log, and error aggregation helpers |
|
|
172
|
+
| `worker-liveness` | `src/commands/inspect/worker-liveness.mjs` | Report whether pending workers are still alive, so the lead's poll ends a stalled wait early instead of paying the deadline (Python: `okstra_ctl.worker_liveness`) |
|
|
172
173
|
| `context-cost` | `src/commands/inspect/context-cost.mjs` | Estimate task bundle file/read context cost |
|
|
173
174
|
| `worktree-lookup` | `src/commands/execute/worktree-lookup.mjs` | Look up a task-key's registered worktree |
|
|
174
175
|
| `plan-validate` | `src/commands/execute/plan-validate.mjs` | Check approved-plan approval marker |
|
|
@@ -249,6 +250,7 @@ Important modules:
|
|
|
249
250
|
| `index.py`, `jsonl.py`, `reconcile.py`, `listing.py`, `batch.py`, `backfill.py` | `~/.okstra` run index and history operations |
|
|
250
251
|
| `run_index_row.py` | single reference point for creating / slimming / hydrating a `~/.okstra` run-index row — runId SSOT, preserves projectId raw |
|
|
251
252
|
| `error_report.py`, `error_log_core.py`, `error_zip.py` | backend for the okstra-inspect errors/error-zip facets — `error_log_core` is the read-only core that globs/parses/aggregates `errors-*.jsonl`, `error_report` renders the errors facet, and `error_zip` collects cross-project run directories, allowlist-anonymizes, aggregates clusters, and produces a zip |
|
|
253
|
+
| `worker_heartbeat.py`, `worker_liveness.py` | `worker_heartbeat` is the single definition of the `- PROGRESS:` heartbeat line shape and its 5-minute (+60s grace) cadence budget, shared by the Phase 7 audit (`validators/validate_session_conformance.py`) and the live probe; `worker_liveness` backs `okstra worker-liveness`, reporting a pending worker as `stalled` (heartbeat past the budget) or `did-not-launch` (no wrapper `.log`/`.status.json` past the launch grace) |
|
|
252
254
|
| `log_report.py`, `time_report.py` | read-side backend for the okstra-inspect logs/time facets (`okstra log-report` is the wrapper sidecar log inventory, `okstra time-report` is per-task time aggregation) |
|
|
253
255
|
| `rollup.py` | read-side backend for the okstra-rollup skill — fans the catalog out per task-group (or the whole project) and deterministically aggregates each task's run count, elapsed time (raw ms), error count, and latest report path, plus group-level totals/status, category, and phase distribution. Reuses the `time_report`/`error_log_core` functions and delegates report-body synthesis to the skill |
|
|
254
256
|
| `usage_report.py` | Read-only okstra-usage backend — scans the whole current project's recent run timelines, defaults to 30 days, and returns task-type coverage, raw/billable tokens, known USD cost, CPU-sum and wall-clock milliseconds, unavailable reason counts, and unmatched pricing models |
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -67,7 +67,7 @@ Before writing the data.json, you MUST:
|
|
|
67
67
|
|
|
68
68
|
For the report writer specifically, the `## Inputs` list always includes:
|
|
69
69
|
|
|
70
|
-
- `<instruction-set>/final-report-schema.json` — the **per-task-type excerpt** of the data.json schema (scoped to this run's task-type at prep time: other task-types' deliverable blocks and their unreachable `$defs` are stripped). This is the shape you must author. Read this, NOT the full `schemas/final-report-v1.0.schema.json` (it is not in the task bundle and its `schemas/...` path is not resolvable here). Validation still runs against the full schema post-hoc, so the excerpt never relaxes the contract.
|
|
70
|
+
- `<instruction-set>/final-report-schema.json` — the **per-task-type excerpt** of the data.json schema (scoped to this run's task-type at prep time: other task-types' deliverable blocks and their unreachable `$defs` are stripped). This is the shape you must author. Read this, NOT the full `schemas/final-report-v1.0.schema.json` (it is not in the task bundle and its `schemas/...` path is not resolvable here). Validation still runs against the full schema post-hoc, so the excerpt never relaxes the contract. The excerpt is frozen at prep time and carries the okstra version it was cut from (`x-okstraCutFromVersion`); if the renderer rejects a field the excerpt told you to write, the runtime upgraded mid-run — the renderer's error names the version skew, and the **installed schema wins**.
|
|
71
71
|
- `<instruction-set>/final-report-template.md` — the **phase-stripped** Jinja2 template the renderer uses (only this run's §4.x deliverable block remains). Read it to understand which data.json fields appear where in the rendered markdown; do NOT edit it, and do NOT pull the full `templates/reports/final-report.template.md` source.
|
|
72
72
|
- `templates/reports/i18n/en.json` and `templates/reports/i18n/ko.json`.
|
|
73
73
|
- Every analysis worker's result file under `worker-results/`.
|
|
@@ -283,8 +283,14 @@ if (( idle_timeout_secs > 0 )); then
|
|
|
283
283
|
# GNU `stat -c` 우선 + 숫자 검증. BSD-ism `stat -f %m` 은 Linux 에서 깨끗이
|
|
284
284
|
# 실패하지 않고 비숫자 파일시스템 출력으로 성공해 아래 산술을 set -u 에서
|
|
285
285
|
# 깨뜨린다 — file_mtime(lib/okstra/interactive.sh) 와 동일 가드.
|
|
286
|
-
|
|
287
|
-
|
|
286
|
+
#
|
|
287
|
+
# `|| true` 는 필수다. macOS 에는 GNU `stat -c` 가 없어 exit 1 이고,
|
|
288
|
+
# `2>/dev/null` 은 stderr 만 막지 종료코드는 막지 못한다. 이 워치독은 명시적
|
|
289
|
+
# `( ) &` 서브셸이라 위쪽 `set -e` 를 상속하므로, 실패한 할당 하나가 첫 폴링에서
|
|
290
|
+
# 워치독을 통째로 죽인다 — 조용히, 로그 한 줄 없이. file_mtime 이 같은 코드로도
|
|
291
|
+
# 멀쩡한 것은 항상 `$( )` 안에서 호출돼 errexit 을 상속하지 않기 때문이다.
|
|
292
|
+
last_mtime="$(stat -c %Y "$log_path" 2>/dev/null || true)"
|
|
293
|
+
[[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime="$(stat -f %m "$log_path" 2>/dev/null || true)"
|
|
288
294
|
[[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime=0
|
|
289
295
|
now=$(date +%s)
|
|
290
296
|
idle=$(( now - last_mtime ))
|
|
@@ -126,8 +126,14 @@ if (( idle_timeout_secs > 0 )); then
|
|
|
126
126
|
# GNU `stat -c` 우선 + 숫자 검증. BSD-ism `stat -f %m` 은 Linux 에서 깨끗이
|
|
127
127
|
# 실패하지 않고 비숫자 파일시스템 출력으로 성공해 아래 산술을 set -u 에서
|
|
128
128
|
# 깨뜨린다 — file_mtime(lib/okstra/interactive.sh) 와 동일 가드.
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
#
|
|
130
|
+
# `|| true` 는 필수다. macOS 에는 GNU `stat -c` 가 없어 exit 1 이고,
|
|
131
|
+
# `2>/dev/null` 은 stderr 만 막지 종료코드는 막지 못한다. 이 워치독은 명시적
|
|
132
|
+
# `( ) &` 서브셸이라 위쪽 `set -e` 를 상속하므로, 실패한 할당 하나가 첫 폴링에서
|
|
133
|
+
# 워치독을 통째로 죽인다 — 조용히, 로그 한 줄 없이. file_mtime 이 같은 코드로도
|
|
134
|
+
# 멀쩡한 것은 항상 `$( )` 안에서 호출돼 errexit 을 상속하지 않기 때문이다.
|
|
135
|
+
last_mtime="$(stat -c %Y "$log_path" 2>/dev/null || true)"
|
|
136
|
+
[[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime="$(stat -f %m "$log_path" 2>/dev/null || true)"
|
|
131
137
|
[[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime=0
|
|
132
138
|
now=$(date +%s)
|
|
133
139
|
idle=$(( now - last_mtime ))
|
|
@@ -167,6 +167,57 @@ log_path="${prompt_path%.md}.log"
|
|
|
167
167
|
[[ "$log_path" == "$prompt_path" ]] && log_path="${prompt_path}.log"
|
|
168
168
|
: > "$log_path"
|
|
169
169
|
|
|
170
|
+
# Per-block line cap applied to the LOG COPY of codex's stdout (see
|
|
171
|
+
# okstra_log_mirror below). Workers read their required inputs end-to-end per
|
|
172
|
+
# templates/worker-prompt-preamble.md "Reading rules", so one report read can
|
|
173
|
+
# dump 170KB+ into the log; observed sidecar logs reach 8MB and account for
|
|
174
|
+
# ~93% of a project's `.okstra/` bytes (`okstra log-report` inventories them).
|
|
175
|
+
# 120 lines keeps each block's command and the head of its output — enough to
|
|
176
|
+
# reconstruct what ran — while cutting the whole-file dumps that dominate.
|
|
177
|
+
log_block_line_cap=120
|
|
178
|
+
|
|
179
|
+
# Mirror stdin to stdout verbatim AND to <log-path> with a per-block cap.
|
|
180
|
+
#
|
|
181
|
+
# Only the log copy is capped. The stdout passthrough is never filtered, so the
|
|
182
|
+
# dispatching subagent's `BashOutput` still receives codex's output verbatim for
|
|
183
|
+
# Phase 5 synthesis, and the model's own context is unaffected — the log is a
|
|
184
|
+
# *copy* of output codex already produced, not an input to anything except a
|
|
185
|
+
# human post-mortem and the `tail -n 10` diagnostic in
|
|
186
|
+
# agents/workers/_cli-wrapper-template.md.
|
|
187
|
+
#
|
|
188
|
+
# A "block" starts at one of codex's own top-level markers. Misreading a marker
|
|
189
|
+
# only resets the counter (less truncation); it can never drop a line from
|
|
190
|
+
# stdout. The marker set is codex-specific — the claude wrapper emits
|
|
191
|
+
# `--output-format=stream-json` and must NOT reuse this filter as-is.
|
|
192
|
+
okstra_log_mirror() {
|
|
193
|
+
awk -v logf="$1" -v cap="$2" '
|
|
194
|
+
function drain() {
|
|
195
|
+
if (elided > 0) {
|
|
196
|
+
printf " [okstra log-mirror] %d line(s) elided from this block\n", elided >> logf
|
|
197
|
+
fflush(logf)
|
|
198
|
+
elided = 0
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/^(exec|codex|thinking|user)$/ { drain(); kept = 0 }
|
|
202
|
+
{
|
|
203
|
+
print
|
|
204
|
+
fflush()
|
|
205
|
+
kept++
|
|
206
|
+
if (kept <= cap) {
|
|
207
|
+
print >> logf
|
|
208
|
+
fflush(logf)
|
|
209
|
+
} else {
|
|
210
|
+
elided++
|
|
211
|
+
# Periodic drain keeps the log mtime moving: the idle watchdog below
|
|
212
|
+
# polls that mtime as its liveness proxy and would otherwise SIGTERM
|
|
213
|
+
# codex partway through a long elided block.
|
|
214
|
+
if (elided >= 500) drain()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
END { drain(); close(logf) }
|
|
218
|
+
'
|
|
219
|
+
}
|
|
220
|
+
|
|
170
221
|
# stdout-mirror FIFO path (created at the dispatch block below). Declared here,
|
|
171
222
|
# before the EXIT trap is defined, so the trap's cleanup reference is always set
|
|
172
223
|
# under `set -u`.
|
|
@@ -293,11 +344,11 @@ fi
|
|
|
293
344
|
# stdin redirect, stderr capture, and pipeline mirroring are intentionally
|
|
294
345
|
# inside the wrapper — this is the entire reason this script exists.
|
|
295
346
|
#
|
|
296
|
-
# stdout:
|
|
297
|
-
#
|
|
298
|
-
#
|
|
299
|
-
#
|
|
300
|
-
# can SIGTERM from the watchdog.
|
|
347
|
+
# stdout: mirrored to both the live log (for `tail -f`, capped per block by
|
|
348
|
+
# okstra_log_mirror) AND the wrapper's own stdout (verbatim, so the
|
|
349
|
+
# subagent's `BashOutput` still captures the final text for Phase 5
|
|
350
|
+
# synthesis). Implemented via a FIFO so codex itself stays a single
|
|
351
|
+
# addressable PID we can SIGTERM from the watchdog.
|
|
301
352
|
# stderr: appended to the live log only — mirrors the prior `2>/dev/null`
|
|
302
353
|
# contract of keeping the wrapper's stderr stream clean.
|
|
303
354
|
# exit: codex's own exit code is preserved by `wait`.
|
|
@@ -325,7 +376,7 @@ fi
|
|
|
325
376
|
stdout_fifo="${log_path}.stdout.fifo"
|
|
326
377
|
rm -f "$stdout_fifo"
|
|
327
378
|
mkfifo "$stdout_fifo"
|
|
328
|
-
|
|
379
|
+
okstra_log_mirror "$log_path" "$log_block_line_cap" < "$stdout_fifo" &
|
|
329
380
|
stdout_tee_pid=$!
|
|
330
381
|
|
|
331
382
|
# Disable git's fsmonitor for every git command codex runs in this process
|
|
@@ -363,8 +414,14 @@ if (( idle_timeout_secs > 0 )); then
|
|
|
363
414
|
# GNU `stat -c` 우선 + 숫자 검증. BSD-ism `stat -f %m` 은 Linux 에서 깨끗이
|
|
364
415
|
# 실패하지 않고 비숫자 파일시스템 출력으로 성공해 아래 산술을 set -u 에서
|
|
365
416
|
# 깨뜨린다 — file_mtime(lib/okstra/interactive.sh) 와 동일 가드.
|
|
366
|
-
|
|
367
|
-
|
|
417
|
+
#
|
|
418
|
+
# `|| true` 는 필수다. macOS 에는 GNU `stat -c` 가 없어 exit 1 이고,
|
|
419
|
+
# `2>/dev/null` 은 stderr 만 막지 종료코드는 막지 못한다. 이 워치독은 명시적
|
|
420
|
+
# `( ) &` 서브셸이라 위쪽 `set -e` 를 상속하므로, 실패한 할당 하나가 첫 폴링에서
|
|
421
|
+
# 워치독을 통째로 죽인다 — 조용히, 로그 한 줄 없이. file_mtime 이 같은 코드로도
|
|
422
|
+
# 멀쩡한 것은 항상 `$( )` 안에서 호출돼 errexit 을 상속하지 않기 때문이다.
|
|
423
|
+
last_mtime="$(stat -c %Y "$log_path" 2>/dev/null || true)"
|
|
424
|
+
[[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime="$(stat -f %m "$log_path" 2>/dev/null || true)"
|
|
368
425
|
[[ "$last_mtime" =~ ^[0-9]+$ ]] || last_mtime=0
|
|
369
426
|
now=$(date +%s)
|
|
370
427
|
idle=$(( now - last_mtime ))
|
|
@@ -62,6 +62,7 @@ This adapter maps the neutral Okstra lead operations to Claude Code host primiti
|
|
|
62
62
|
- Follow the core Result Path + terminal-status completion contract. The Claude adapter's wake mechanism is one `Bash(run_in_background: true)` poll covering every pending Result Path, not foreground sleep or an idle-notification dependency. A spawn acknowledgement is never completion.
|
|
63
63
|
- The background poll uses a per-worker deadline of twice the expected duration: 20 minutes for `requirements-discovery`, 30 for `error-analysis`, 40 for `implementation-planning`, 40 for `implementation`, and 20 for `final-verification`. On timeout, record terminal status and apply the core's single shared retry budget.
|
|
64
64
|
- The Claude worker heartbeat audit sidecar must update at least every five minutes while its result is pending. A missing or stale heartbeat consumes the same one-retry budget; after the second silent hang, record `timeout`. The result file remains the authoritative completion signal.
|
|
65
|
+
- **The background poll checks liveness, not only Result Paths.** Result Paths change once, at the very end, so polling them alone pays the full deadline for a worker that died at minute three. Each poll iteration MUST also run, in the same background shell, one `okstra worker-liveness` call covering every pending worker — `--audit <audit-sidecar-path>` for each in-process Claude worker, `--prompt <prompt-history-path>` for each CLI-wrapper worker. It exits non-zero when a worker is `stalled` (heartbeat older than the cadence budget) or `did-not-launch`; either verdict ends the wait for that worker immediately and spends the core's one-retry budget, rather than waiting out the deadline. The command reports only — it never kills or re-dispatches. It shares its heartbeat budget with the Phase 7 audit (`okstra_ctl.worker_heartbeat`), so a worker the live probe passes cannot fail the post-hoc one for cadence.
|
|
65
66
|
- The Claude Code harness blocks long foreground sleeps and shorter-sleep circumvention loops. Keep the result poll in a single background shell and let wrapper agents use their documented `BashOutput` loop.
|
|
66
67
|
- On approved cleanup, reconcile the current live session roster before sending shutdown requests. Never target the lead session.
|
|
67
68
|
- Collect usage before teardown. Resume through the recorded Claude session id and keep all run artifacts authoritative.
|
|
@@ -255,7 +255,7 @@ Call `await_workers(handles)` through the same adapter and apply the shared term
|
|
|
255
255
|
|
|
256
256
|
### Required reverify-prompt anchor headers (BLOCKING)
|
|
257
257
|
|
|
258
|
-
Every reverify prompt MUST start with these
|
|
258
|
+
Every reverify prompt MUST start with these 8 anchor headers — in this exact order, before any other content:
|
|
259
259
|
|
|
260
260
|
```
|
|
261
261
|
**Project Root:** <absolute-path>
|
|
@@ -265,8 +265,11 @@ Assigned worker prompt history path: <Project Root>/<Prompt History Path>
|
|
|
265
265
|
**Model:** <role>, <modelExecutionValue>
|
|
266
266
|
**Errors log path:** <absolute-path>
|
|
267
267
|
**Errors sidecar path:** <absolute-path>
|
|
268
|
+
**Read scope:** Read only the paths this prompt enumerates (`[Required reading]`, `## Inputs`, verification-target paths) plus source/evidence paths a finding must cite. Host session instructions (SessionStart hooks, global `CLAUDE.md` / `AGENTS.md`, skill catalogs) do NOT apply inside an okstra worker run: do not auto-read `graphify-out/`, `SKILL.md`, or other artifacts outside `<PROJECT_ROOT>/.okstra/`. If an un-enumerated file seems essential, record it under *Missing Information or Assumptions* instead of reading it.
|
|
268
269
|
```
|
|
269
270
|
|
|
271
|
+
`**Read scope:**` is carried verbatim, not dropped: lightweight mode omits `**Worker Preamble Path:**`, so this line is the ONLY place a reverify worker receives the read allowlist — and a prompt with no `[Required reading]` block is exactly where a worker is most likely to fall back on host session instructions instead.
|
|
272
|
+
|
|
270
273
|
The two errors paths carry the same absolute values the lead forwarded in the initial Phase 4 dispatch for that role (source: the launch prompt's `## Run Logs (error-log wiring)` section). Omitting either one makes a CLI-wrapper worker return `<WORKER>_ERRORS_PATH_MISSING` before it invokes its CLI — the path-delivery contract in [team-contract](./team-contract.md) "Error reporting" is not relaxed for reverify, because a reverify dispatch can fail the same way an initial dispatch can.
|
|
271
274
|
|
|
272
275
|
Relative to the Phase 4 anchor set rendered by `okstra_ctl.worker_prompt_headers.worker_prompt_headers()`, a reverify prompt adds `**Model:**` and drops two anchors whose targets lightweight mode never reads: `**Worker Preamble Path:**` and `**Coding preflight pack:**`.
|
|
@@ -244,7 +244,30 @@ okstra error-log append-from-worker \
|
|
|
244
244
|
--task-key <taskKey> --agent <agent> --agent-role <role> --model <model>
|
|
245
245
|
```
|
|
246
246
|
|
|
247
|
-
|
|
247
|
+
`--agent`, `--agent-role`, and `--error-type` are **closed enums**, not free-form labels — the role names used elsewhere in these contracts (`Codex worker`, `Claude worker`) are rejected. Use exactly:
|
|
248
|
+
|
|
249
|
+
- `--agent` — `claude-worker` | `codex-worker` | `antigravity-worker` | `report-writer`
|
|
250
|
+
- `--agent-role` — `lead` | `worker` | `report-writer`
|
|
251
|
+
- `--error-type` — `cli-failure` | `contract-violation` | `tool-failure`
|
|
252
|
+
|
|
253
|
+
For a lead-attributed event there is no value in the list above — the selected adapter names the lead identity to pass.
|
|
254
|
+
|
|
255
|
+
For Codex/Antigravity wrappers: if the CLI returns non-zero, times out, or hits a rate limit, immediately call `append-observed` with the captured exit code, duration, message, and stderr excerpt. `append-observed` additionally requires `--phase`, `--command`, and `--command-kind`, so copy this form rather than trimming the one above:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
okstra error-log append-observed \
|
|
259
|
+
--out <absolute-errors-log-path-from-launch-prompt> \
|
|
260
|
+
--task-key <taskKey> --phase 5.5 \
|
|
261
|
+
--agent codex-worker --agent-role worker --model <model> \
|
|
262
|
+
--error-type cli-failure \
|
|
263
|
+
--command "okstra-codex-exec.sh <project-root> <model> <prompt-path>" \
|
|
264
|
+
--command-kind wrapper \
|
|
265
|
+
--exit-code 124 --duration-ms 1800000 \
|
|
266
|
+
--message "reverify-r1 wrapper never launched" \
|
|
267
|
+
--stderr-excerpt "<last stderr lines, or use --stderr-excerpt-file>"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The wrapper subagent records this through its selected adapter — Lead does NOT need to re-record. Token usage is not inferred from dispatch return values; call `collect_usage` at the start of Phase 7.
|
|
248
271
|
|
|
249
272
|
## Phase 5.5: Convergence loop
|
|
250
273
|
|
|
@@ -43,7 +43,7 @@ The prompt MUST include, in this order at the top:
|
|
|
43
43
|
- `**Errors sidecar path:** <absolute-path>` — this worker's per-run sidecar JSON (`worker-results/report-writer-worker-errors-<task-type>-<seq>.json`).
|
|
44
44
|
7. `**Model:** Report writer worker, <modelExecutionValue>` (resolved per Phase 5.5 anchor-header rules)
|
|
45
45
|
8. The full `[Required reading]` clause (see [team-contract](./team-contract.md)) — for Phase 6 it adds two **per-task-type, instruction-set-local** read-only files, both scoped to this run's task-type by `okstra-ctl` at prep time:
|
|
46
|
-
- `<instruction-set>/final-report-schema.json` — a task-type excerpt of the data.json schema (the other task-types' deliverable blocks and their unreachable `$defs` are stripped; ~38% of the full schema is `$defs` alone). This is your authoring
|
|
46
|
+
- `<instruction-set>/final-report-schema.json` — a task-type excerpt of the data.json schema (the other task-types' deliverable blocks and their unreachable `$defs` are stripped; ~38% of the full schema is `$defs` alone). This is your authoring aid for the data.json shape — the installed schema, not the excerpt, is what the run is judged against. Do **NOT** pull the full `schemas/final-report-v1.0.schema.json` — it carries all task-types and its `schemas/...` path is not part of the task bundle. (Validation still runs against the full schema post-hoc via the renderer, so the excerpt never relaxes the contract.)
|
|
47
47
|
- `<instruction-set>/final-report-template.md` — the **phase-stripped** template (every other task-type's §5.x deliverable block removed by `render.py`'s `_strip_phase_blocks`, leaving only your run's §5.x). Do **NOT** also pull the full `templates/reports/final-report.template.md` source (it re-adds ~330 lines of other phases' deliverables and is not in the task bundle).
|
|
48
48
|
9. A one-line MCP pointer instead of the verbatim block (redundant — the brief is already in the report-writer's Required reading, item 8): `**MCP servers:** follow the task brief's "## Available MCP Servers" section (already in your Required reading).`
|
|
49
49
|
10. The convergence classifications (Full/Partial/Contested/Worker-Unique), the round history data (`roundHistory[]`), the `round2SkippedReason` value, and pointers to all worker result files under `worker-results/`. The report-writer worker populates `crossVerification.roundHistory` in the data.json so Section 6 can show which rounds executed, queue sizes, and why Round 2 was (or was not) skipped. The renderer prints the full per-round table only when more than one round ran; single-round or zero-round histories are auto-collapsed to a one-line summary.
|
|
@@ -118,6 +118,9 @@ After each worker subagent returns (regardless of role), Lead MUST verify the ca
|
|
|
118
118
|
- The wrapper subagent returned an explicit `*_RESULT_MISSING` sentinel (codex-worker / antigravity-worker step 8c — `CODEX_RESULT_MISSING` / `ANTIGRAVITY_RESULT_MISSING`).
|
|
119
119
|
- The result file is absent at the resolved absolute path even though the worker returned without a `*_RESULT_MISSING` sentinel — for example, claude-worker returned its final assistant message but never persisted the artifact, or the wrapper exited 0 and the codex/antigravity sub-agent forwarded raw stdout despite the contract.
|
|
120
120
|
- The result file exists but cannot be parsed (frontmatter unreadable, sections 1–5 entirely missing). A truncated file in the middle of section 5 is NOT covered here — it goes to the validator's regular `error` path, not the retry path.
|
|
121
|
+
- `okstra worker-liveness` reports the worker `did-not-launch` — neither `<prompt-path>.log` nor `<prompt-path>.status.json` exists past the launch grace (default 60s). The wrapper writes its status sidecar before invoking the CLI and hard-fails loudly with a distinct exit code on every argument check before that, so the absence of BOTH artifacts means the dispatch itself never reached the script. Without this trigger the only evidence was a lead noticing two missing files by eye, and the run paid the full polling cap for a worker that never started.
|
|
122
|
+
- `okstra worker-liveness` reports the worker `stalled` — its audit sidecar's newest `- PROGRESS:` heartbeat is older than the cadence budget, or the sidecar carries no heartbeat at all. This is the in-process equivalent of the CLI wrappers' idle watchdog: the wrapper reaps a silent CLI itself, but nothing reaped a silent `claude-worker` until its deadline.
|
|
123
|
+
- The result file exists but its audit sidecar does not, at `runs/<task-type>/worker-results/<worker>-audit-<task-type>-<seq>.md`. Workers write both in the same step, so a result without a sidecar means the Reading Confirmation block — the only evidence the worker read its inputs — was never produced. `validate-run.py` fails the run on this at Phase 7 either way (`validate_worker_results_audit`); checking it here spends the existing one-retry budget while the role can still be re-dispatched, instead of surfacing hours later when the worker session is gone.
|
|
121
124
|
|
|
122
125
|
**One-retry policy:**
|
|
123
126
|
|
|
@@ -180,11 +180,28 @@ def _stage_rows(planning: Mapping[str, Any]) -> dict[int, Mapping[str, Any]]:
|
|
|
180
180
|
return rows
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
_LINE_RANGE_SUFFIX = re.compile(r":\d+(?:-\d+)?$")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _stages_for_selected_path(
|
|
184
187
|
path: str,
|
|
185
188
|
stages: Mapping[int, Mapping[str, Any]],
|
|
186
|
-
) -> int:
|
|
187
|
-
|
|
189
|
+
) -> list[int]:
|
|
190
|
+
"""Every stage whose stepwise ``files`` cells touch *path*.
|
|
191
|
+
|
|
192
|
+
A file legitimately edited by more than one stage — the task-level
|
|
193
|
+
``qa/conformance-manifest.json`` is the standard case, since each
|
|
194
|
+
conformance-declaring stage registers its own entry — yields one design
|
|
195
|
+
surface per stage, which is what the consumer keys on. Requiring a single
|
|
196
|
+
owner instead would force the plan to hide an edit, collapse stage
|
|
197
|
+
boundaries, or re-add the line-range suffixes that used to disambiguate
|
|
198
|
+
stages by accident.
|
|
199
|
+
|
|
200
|
+
Zero stages is still an error: the recommended option declares a file that
|
|
201
|
+
no step creates or edits.
|
|
202
|
+
"""
|
|
203
|
+
normalised = _LINE_RANGE_SUFFIX.sub("", _normalise(path))
|
|
204
|
+
path_pattern = rf"(?<![\w./@-]){re.escape(normalised)}(?![\w./@-])"
|
|
188
205
|
matched = []
|
|
189
206
|
for stage_number, stage in stages.items():
|
|
190
207
|
files = [
|
|
@@ -192,14 +209,13 @@ def _stage_for_selected_path(
|
|
|
192
209
|
for step in stage.get("stepwiseExecution") or []
|
|
193
210
|
if isinstance(step, Mapping)
|
|
194
211
|
]
|
|
195
|
-
path_pattern = rf"(?<![\w./@-]){re.escape(normalised)}(?![\w./@-])"
|
|
196
212
|
if any(re.search(path_pattern, cell) is not None for cell in files):
|
|
197
213
|
matched.append(stage_number)
|
|
198
|
-
if
|
|
214
|
+
if not matched:
|
|
199
215
|
raise DesignSurfaceError(
|
|
200
|
-
f"selected option path {path!r} is not mapped to a stage
|
|
216
|
+
f"selected option path {path!r} is not mapped to a stage"
|
|
201
217
|
)
|
|
202
|
-
return matched
|
|
218
|
+
return matched
|
|
203
219
|
|
|
204
220
|
|
|
205
221
|
def detect_design_surfaces(
|
|
@@ -226,13 +242,13 @@ def detect_design_surfaces(
|
|
|
226
242
|
if not isinstance(row, Mapping):
|
|
227
243
|
continue
|
|
228
244
|
path = str(row.get("path") or "")
|
|
229
|
-
stage_number
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
245
|
+
for stage_number in _stages_for_selected_path(path, stages):
|
|
246
|
+
for kind, evidence in _matching_rule_evidence(
|
|
247
|
+
step=None,
|
|
248
|
+
field="files",
|
|
249
|
+
value=path,
|
|
250
|
+
):
|
|
251
|
+
grouped.setdefault((stage_number, kind), []).append(evidence)
|
|
236
252
|
rule_order = {rule.kind: index for index, rule in enumerate(RULES)}
|
|
237
253
|
return [
|
|
238
254
|
DesignSurfaceTrigger(stage, kind, tuple(grouped[(stage, kind)]))
|
|
@@ -52,6 +52,8 @@ from okstra_ctl.final_report_schema import SchemaError, load_schema, validate as
|
|
|
52
52
|
from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
|
|
53
53
|
from okstra_ctl.md_table import UNESCAPED_PIPE_RE, escape_pipes
|
|
54
54
|
from okstra_ctl.models import UnknownModelError, resolve_model_metadata
|
|
55
|
+
from okstra_ctl.schema_excerpt import excerpt_cut_from_version
|
|
56
|
+
from okstra_ctl.seeding import installed_version
|
|
55
57
|
|
|
56
58
|
|
|
57
59
|
DEFAULT_TEMPLATE_REL = ("templates", "reports", "final-report.template.md")
|
|
@@ -125,6 +127,21 @@ def _yaml_inline_list(values: list[str]) -> str:
|
|
|
125
127
|
return "[" + ", ".join(_yaml_scalar(v) for v in values) + "]"
|
|
126
128
|
|
|
127
129
|
|
|
130
|
+
def _md_quote(value: str | None, width: int = 2) -> str:
|
|
131
|
+
"""Re-mark every continuation line of a multi-line blockquote value.
|
|
132
|
+
|
|
133
|
+
The template supplies the first line's `` > ``; lines 2..n arrive at
|
|
134
|
+
column 0, where they end the enclosing list item and drag the bullets
|
|
135
|
+
that follow into one run-on paragraph.
|
|
136
|
+
"""
|
|
137
|
+
text = "" if value is None else str(value)
|
|
138
|
+
pad = " " * width
|
|
139
|
+
lines = text.split("\n")
|
|
140
|
+
return "\n".join(
|
|
141
|
+
[lines[0]] + [f"{pad}>{' ' + line if line else ''}" for line in lines[1:]]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
128
145
|
# --- Index / scroll-anchor post-render pass -------------------------------
|
|
129
146
|
# After Jinja2 renders the body, every report gets a top-of-report index and
|
|
130
147
|
# clickable scroll anchors. An ID is *defined* when it is the leading token of
|
|
@@ -424,6 +441,11 @@ def _build_environment(template_dir: Path) -> Environment:
|
|
|
424
441
|
# cannot split a markdown table row. Table-cell interpolations only —
|
|
425
442
|
# never code blocks / headings / prose, where `\|` would render verbatim.
|
|
426
443
|
env.filters["mdcell"] = escape_pipes
|
|
444
|
+
# `mdquote` re-marks continuation lines of a blockquote nested in a list
|
|
445
|
+
# item; the fenced siblings use Jinja's builtin `indent` for the same
|
|
446
|
+
# reason. Both exist because only the first line of an interpolation
|
|
447
|
+
# inherits the template's leading indent.
|
|
448
|
+
env.filters["mdquote"] = _md_quote
|
|
427
449
|
return env
|
|
428
450
|
|
|
429
451
|
|
|
@@ -516,6 +538,47 @@ def find_default_template(start: Path | None = None) -> Path:
|
|
|
516
538
|
)
|
|
517
539
|
|
|
518
540
|
|
|
541
|
+
def _bundle_excerpt_path(data_path: Path) -> Path | None:
|
|
542
|
+
"""The task bundle's schema excerpt, found by walking up from *data_path*."""
|
|
543
|
+
for ancestor in data_path.resolve().parents:
|
|
544
|
+
candidate = ancestor / "instruction-set" / "final-report-schema.json"
|
|
545
|
+
if candidate.is_file():
|
|
546
|
+
return candidate
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _with_excerpt_drift_hint(
|
|
551
|
+
exc: FinalReportRenderError, data_path: Path
|
|
552
|
+
) -> FinalReportRenderError:
|
|
553
|
+
"""Name the version skew behind a schema failure, when that is the cause.
|
|
554
|
+
|
|
555
|
+
A long run straddles its own runtime upgrade: the report-writer authors
|
|
556
|
+
against the excerpt frozen into the bundle at prep time, while the renderer
|
|
557
|
+
validates against the installed schema. Without this the author sees only
|
|
558
|
+
`additional property ... not allowed` for a field the excerpt told it to
|
|
559
|
+
write, and has no way to tell a real mistake from a stale bundle.
|
|
560
|
+
"""
|
|
561
|
+
if "schema validation" not in str(exc):
|
|
562
|
+
return exc
|
|
563
|
+
excerpt_path = _bundle_excerpt_path(data_path)
|
|
564
|
+
if excerpt_path is None:
|
|
565
|
+
return exc
|
|
566
|
+
try:
|
|
567
|
+
cut_from = excerpt_cut_from_version(
|
|
568
|
+
json.loads(excerpt_path.read_text(encoding="utf-8"))
|
|
569
|
+
)
|
|
570
|
+
except (OSError, json.JSONDecodeError):
|
|
571
|
+
return exc
|
|
572
|
+
current = installed_version()
|
|
573
|
+
if not cut_from or not current or cut_from == current:
|
|
574
|
+
return exc
|
|
575
|
+
return FinalReportRenderError(
|
|
576
|
+
f"{exc} — the bundle's schema excerpt ({excerpt_path}) was cut from okstra "
|
|
577
|
+
f"{cut_from} but validation ran on {current}; author against the installed "
|
|
578
|
+
"schema, not the excerpt, or re-prepare the bundle."
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
519
582
|
def render_to_file(
|
|
520
583
|
data_path: Path,
|
|
521
584
|
output_path: Path,
|
|
@@ -542,11 +605,14 @@ def render_to_file(
|
|
|
542
605
|
# 설치본에서 항상 'could not locate template' 으로 실패한다(OKSTRA_HOME 을
|
|
543
606
|
# 수동 설정해야 했던 원인). 프로젝트별 override 는 --template 으로 한다.
|
|
544
607
|
resolved_template = template_path or find_default_template()
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
608
|
+
try:
|
|
609
|
+
rendered = render(
|
|
610
|
+
data,
|
|
611
|
+
template_path=resolved_template,
|
|
612
|
+
report_language=report_language,
|
|
613
|
+
)
|
|
614
|
+
except FinalReportRenderError as exc:
|
|
615
|
+
raise _with_excerpt_drift_hint(exc, data_path) from exc
|
|
550
616
|
|
|
551
617
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
552
618
|
tmp = output_path.with_suffix(output_path.suffix + f".tmp.{os.getpid()}")
|
|
@@ -1709,7 +1709,9 @@ def _render_lead_prompt_and_snapshot(
|
|
|
1709
1709
|
# still has the phase-stripped template + skill structure guide, and
|
|
1710
1710
|
# validation runs against the full schema regardless.
|
|
1711
1711
|
try:
|
|
1712
|
-
_excerpt = build_schema_excerpt(
|
|
1712
|
+
_excerpt = build_schema_excerpt(
|
|
1713
|
+
load_schema(), inp.task_type, installed_version()
|
|
1714
|
+
)
|
|
1713
1715
|
Path(ctx["FINAL_REPORT_SCHEMA_PATH"]).write_text(
|
|
1714
1716
|
json.dumps(_excerpt, indent=2, ensure_ascii=False) + "\n",
|
|
1715
1717
|
encoding="utf-8",
|
|
@@ -72,7 +72,16 @@ def _conditional_applies(entry: dict, task_type: str) -> bool:
|
|
|
72
72
|
return task_type in (enum or [])
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
EXCERPT_VERSION_KEY = "x-okstraCutFromVersion"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def excerpt_cut_from_version(excerpt: dict) -> str:
|
|
79
|
+
"""The okstra version *excerpt* was cut from; empty when unstamped."""
|
|
80
|
+
value = excerpt.get(EXCERPT_VERSION_KEY)
|
|
81
|
+
return value if isinstance(value, str) else ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_schema_excerpt(schema: dict, task_type: str, cut_from_version: str = "") -> dict:
|
|
76
85
|
"""Return a task-type-scoped copy of *schema*.
|
|
77
86
|
|
|
78
87
|
Drops the per-type property blocks that do not belong to *task_type*,
|
|
@@ -81,6 +90,11 @@ def build_schema_excerpt(schema: dict, task_type: str) -> dict:
|
|
|
81
90
|
def reachable from a kept property or conditional). Top-level metadata
|
|
82
91
|
and ``required`` are preserved (``required`` never lists per-type
|
|
83
92
|
blocks, but it is filtered defensively).
|
|
93
|
+
|
|
94
|
+
*cut_from_version* stamps the excerpt with the runtime that produced it.
|
|
95
|
+
Bundle prep and report render can be hours apart and the runtime upgrades
|
|
96
|
+
itself in between, so a mismatch is what turns an opaque "additional
|
|
97
|
+
property … not allowed" into "the bundle excerpt is from an older okstra".
|
|
84
98
|
"""
|
|
85
99
|
keep_per_type = _TASK_TYPE_PROPERTY.get(task_type)
|
|
86
100
|
drop_props = _ALL_PER_TYPE_PROPERTIES - ({keep_per_type} if keep_per_type else set())
|
|
@@ -119,4 +133,6 @@ def build_schema_excerpt(schema: dict, task_type: str) -> dict:
|
|
|
119
133
|
excerpt["allOf"] = all_of
|
|
120
134
|
if reachable:
|
|
121
135
|
excerpt["$defs"] = {k: v for k, v in defs.items() if k in reachable}
|
|
136
|
+
if cut_from_version:
|
|
137
|
+
excerpt[EXCERPT_VERSION_KEY] = cut_from_version
|
|
122
138
|
return excerpt
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Heartbeat primitives shared by the live liveness probe and the Phase 7 audit.
|
|
2
|
+
|
|
3
|
+
`claude-worker` appends `- PROGRESS: <stage> <ISO-8601-UTC>` lines to its audit
|
|
4
|
+
sidecar so a silent hang is visible while the run is still recoverable. The
|
|
5
|
+
Phase 7 validator has always parsed those lines post-hoc; `worker_liveness`
|
|
6
|
+
reads them mid-run. Both must agree on what "stale" means, so the line shape and
|
|
7
|
+
the cadence budget live here rather than in either consumer.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# claude-worker audit 사이드카의 heartbeat 라인 (claude-worker.md "Heartbeat").
|
|
16
|
+
HEARTBEAT_LINE_RE = re.compile(
|
|
17
|
+
r"^-[ \t]*PROGRESS:[ \t]*(?P<stage>\S+)[ \t]+(?P<ts>\S+)[ \t]*$", re.MULTILINE
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# 계약상 cadence 는 5분. append 직전 측정한 시각과 실제 쓰기 사이 지연을 흡수하는
|
|
21
|
+
# 고정 grace 60초를 더한다.
|
|
22
|
+
HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_timestamp(raw: str) -> datetime | None:
|
|
26
|
+
"""A heartbeat's ISO-8601 timestamp as an aware datetime, or None."""
|
|
27
|
+
try:
|
|
28
|
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
29
|
+
except ValueError:
|
|
30
|
+
return None
|
|
31
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def latest_heartbeat(sidecar: Path) -> datetime | None:
|
|
35
|
+
"""The newest parseable heartbeat in *sidecar*, or None when it has none.
|
|
36
|
+
|
|
37
|
+
Takes the maximum rather than the last line: a regressing timestamp is the
|
|
38
|
+
Phase 7 validator's finding to report, and treating it as "stale" here would
|
|
39
|
+
kill a worker that is in fact still writing.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
text = sidecar.read_text(encoding="utf-8")
|
|
43
|
+
except OSError:
|
|
44
|
+
return None
|
|
45
|
+
stamps = [
|
|
46
|
+
parsed
|
|
47
|
+
for match in HEARTBEAT_LINE_RE.finditer(text)
|
|
48
|
+
if (parsed := parse_timestamp(match.group("ts"))) is not None
|
|
49
|
+
]
|
|
50
|
+
return max(stamps) if stamps else None
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Mid-run liveness probe for pending workers (`okstra worker-liveness`).
|
|
2
|
+
|
|
3
|
+
The CLI wrappers reap a silent CLI themselves via their idle watchdog, but the
|
|
4
|
+
lead's own wait had no equivalent: it polled Result Paths, which only change at
|
|
5
|
+
the very end, so a worker that died at minute 3 was still waited on until its
|
|
6
|
+
20–40 minute deadline. Two contracts named that failure without a mechanism —
|
|
7
|
+
`adapters/claude-code.md` ("a missing or stale heartbeat consumes the same
|
|
8
|
+
one-retry budget") and the absence of any `did-not-launch` status at all.
|
|
9
|
+
|
|
10
|
+
This module is that mechanism. It reports, not decides: the lead reads the
|
|
11
|
+
verdict and spends its existing one-retry budget.
|
|
12
|
+
|
|
13
|
+
Two probes, matching the two ways a pending worker goes quiet:
|
|
14
|
+
|
|
15
|
+
* ``--audit`` — a `claude-worker` audit sidecar. Stale past the heartbeat
|
|
16
|
+
cadence (or present with no heartbeat at all) means the in-process worker
|
|
17
|
+
hung. Uses the same line shape and budget the Phase 7 validator applies.
|
|
18
|
+
* ``--prompt`` — a CLI-wrapper prompt-history path. Neither `<prompt>.log` nor
|
|
19
|
+
`<prompt>.status.json` past the launch grace means the wrapper never ran —
|
|
20
|
+
the dispatch itself failed, which no artifact otherwise distinguishes from a
|
|
21
|
+
worker that has merely not finished.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from okstra_ctl.worker_heartbeat import HEARTBEAT_MAX_GAP_SECONDS, latest_heartbeat
|
|
32
|
+
|
|
33
|
+
DEFAULT_LAUNCH_GRACE_SECONDS = 60
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _log_path(prompt: Path) -> Path:
|
|
37
|
+
"""The wrapper's live log, named as okstra-*-exec.sh names it."""
|
|
38
|
+
return prompt.with_suffix(".log") if prompt.suffix == ".md" else Path(f"{prompt}.log")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
|
|
42
|
+
"""Liveness of one in-process worker, read from its audit sidecar."""
|
|
43
|
+
probe = {"kind": "heartbeat", "path": str(sidecar)}
|
|
44
|
+
if not sidecar.is_file():
|
|
45
|
+
# The worker writes the sidecar early but not instantly; absence is the
|
|
46
|
+
# launch probe's business, not a hang.
|
|
47
|
+
return {**probe, "state": "pending", "reason": "audit sidecar not written yet"}
|
|
48
|
+
beat = latest_heartbeat(sidecar)
|
|
49
|
+
if beat is None:
|
|
50
|
+
return {
|
|
51
|
+
**probe,
|
|
52
|
+
"state": "stalled",
|
|
53
|
+
"reason": "audit sidecar carries no `- PROGRESS:` heartbeat",
|
|
54
|
+
}
|
|
55
|
+
idle = (now - beat).total_seconds()
|
|
56
|
+
state = "stalled" if idle > max_idle else "live"
|
|
57
|
+
return {
|
|
58
|
+
**probe,
|
|
59
|
+
"state": state,
|
|
60
|
+
"idleSeconds": int(idle),
|
|
61
|
+
"lastHeartbeat": beat.isoformat(),
|
|
62
|
+
"reason": (
|
|
63
|
+
f"no heartbeat for {int(idle)}s (budget {int(max_idle)}s)"
|
|
64
|
+
if state == "stalled"
|
|
65
|
+
else ""
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def probe_launch(prompt: Path, now: datetime, grace: float) -> dict:
|
|
71
|
+
"""Whether the wrapper behind *prompt* ever started."""
|
|
72
|
+
log, status = _log_path(prompt), Path(f"{prompt}.status.json")
|
|
73
|
+
probe = {"kind": "launch", "path": str(prompt)}
|
|
74
|
+
if log.exists() or status.exists():
|
|
75
|
+
return {**probe, "state": "live", "reason": ""}
|
|
76
|
+
if not prompt.is_file():
|
|
77
|
+
return {**probe, "state": "pending", "reason": "prompt history not written yet"}
|
|
78
|
+
waited = now.timestamp() - prompt.stat().st_mtime
|
|
79
|
+
if waited <= grace:
|
|
80
|
+
return {**probe, "state": "pending", "reason": "within launch grace"}
|
|
81
|
+
return {
|
|
82
|
+
**probe,
|
|
83
|
+
"state": "did-not-launch",
|
|
84
|
+
"waitedSeconds": int(waited),
|
|
85
|
+
"reason": (
|
|
86
|
+
f"neither {log.name} nor {status.name} exists {int(waited)}s after the "
|
|
87
|
+
f"prompt was written (grace {int(grace)}s)"
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def probe_all(
|
|
93
|
+
audits: list[str],
|
|
94
|
+
prompts: list[str],
|
|
95
|
+
*,
|
|
96
|
+
now: datetime,
|
|
97
|
+
max_idle: float,
|
|
98
|
+
launch_grace: float,
|
|
99
|
+
) -> dict:
|
|
100
|
+
probes = [probe_heartbeat(Path(p), now, max_idle) for p in audits]
|
|
101
|
+
probes += [probe_launch(Path(p), now, launch_grace) for p in prompts]
|
|
102
|
+
unhealthy = [p for p in probes if p["state"] in ("stalled", "did-not-launch")]
|
|
103
|
+
return {"ok": not unhealthy, "checkedAt": now.isoformat(), "probes": probes,
|
|
104
|
+
"unhealthy": unhealthy}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main(argv: list[str] | None = None) -> int:
|
|
108
|
+
parser = argparse.ArgumentParser(
|
|
109
|
+
prog="okstra worker-liveness",
|
|
110
|
+
description="Report whether pending workers are still alive (read-only).",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument("--audit", action="append", default=[],
|
|
113
|
+
help="claude-worker audit sidecar path (repeatable)")
|
|
114
|
+
parser.add_argument("--prompt", action="append", default=[],
|
|
115
|
+
help="CLI-wrapper prompt-history path (repeatable)")
|
|
116
|
+
parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
|
|
117
|
+
help="heartbeat staleness budget in seconds")
|
|
118
|
+
parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
|
|
119
|
+
help="seconds a wrapper may take to write its first artifact")
|
|
120
|
+
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
121
|
+
args = parser.parse_args(argv)
|
|
122
|
+
|
|
123
|
+
if not args.audit and not args.prompt:
|
|
124
|
+
parser.error("pass at least one --audit or --prompt path")
|
|
125
|
+
|
|
126
|
+
result = probe_all(
|
|
127
|
+
args.audit,
|
|
128
|
+
args.prompt,
|
|
129
|
+
now=datetime.now(timezone.utc),
|
|
130
|
+
max_idle=args.max_idle,
|
|
131
|
+
launch_grace=args.launch_grace,
|
|
132
|
+
)
|
|
133
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
134
|
+
# Non-zero on an unhealthy worker so a poll loop can branch on the exit code
|
|
135
|
+
# without parsing the JSON.
|
|
136
|
+
return 0 if result["ok"] else 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -11,6 +11,25 @@ class WorkerPromptHeaderError(Exception):
|
|
|
11
11
|
"""Raised when worker prompt anchor headers cannot be rendered."""
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
# The Worker Preamble's "Reading rules" already allowlist the worker's reads to
|
|
15
|
+
# okstra-enumerated paths, but the preamble is a *path* the worker opens partway
|
|
16
|
+
# into its run — by then a host SessionStart hook or a global CLAUDE.md /
|
|
17
|
+
# AGENTS.md project-conditional has already told it to read `graphify-out/`,
|
|
18
|
+
# skill catalogs, and similar non-okstra artifacts. Restating the scope inline
|
|
19
|
+
# puts the rule in front of the worker before its first tool call, which is the
|
|
20
|
+
# only point at which it can still win over those host instructions.
|
|
21
|
+
READ_SCOPE_HEADER = (
|
|
22
|
+
"**Read scope:** Read only the paths this prompt enumerates "
|
|
23
|
+
"(`[Required reading]`, `## Inputs`, verification-target paths) plus "
|
|
24
|
+
"source/evidence paths a finding must cite. Host session instructions "
|
|
25
|
+
"(SessionStart hooks, global `CLAUDE.md` / `AGENTS.md`, skill catalogs) do "
|
|
26
|
+
"NOT apply inside an okstra worker run: do not auto-read `graphify-out/`, "
|
|
27
|
+
"`SKILL.md`, or other artifacts outside `<PROJECT_ROOT>/.okstra/`. If an "
|
|
28
|
+
"un-enumerated file seems essential, record it under *Missing Information "
|
|
29
|
+
"or Assumptions* instead of reading it."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
14
33
|
def worker_prompt_headers(
|
|
15
34
|
*,
|
|
16
35
|
project_root: Path,
|
|
@@ -34,6 +53,7 @@ def worker_prompt_headers(
|
|
|
34
53
|
f"**Errors sidecar path:** "
|
|
35
54
|
f"{_worker_errors_sidecar_path(project_root, manifest, active_context, worker_id)}"
|
|
36
55
|
),
|
|
56
|
+
READ_SCOPE_HEADER,
|
|
37
57
|
]
|
|
38
58
|
|
|
39
59
|
|
|
@@ -464,14 +464,14 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
|
|
|
464
464
|
|
|
465
465
|
- Path (project-relative): `{{ releaseHandoff.sourceVerificationReport.path }}`
|
|
466
466
|
- Quoted `Verdict Token` row from that report's `## 7.` table:
|
|
467
|
-
> {{ releaseHandoff.sourceVerificationReport.verdictTokenQuote }}
|
|
467
|
+
> {{ releaseHandoff.sourceVerificationReport.verdictTokenQuote | mdquote(2) }}
|
|
468
468
|
|
|
469
469
|
### 5.6.2 Feature Branch & Working-Tree State{% if t("releaseHandoff.branchStateAside") != "captured at run start" %} ({{ t("releaseHandoff.branchStateAside") }}){% endif %}
|
|
470
470
|
|
|
471
471
|
- Feature branch: `{{ releaseHandoff.featureBranchState.branchName }}`
|
|
472
472
|
- {{ t("releaseHandoff.gitStatusShortLabel") }}:
|
|
473
473
|
```
|
|
474
|
-
{{ releaseHandoff.featureBranchState.gitStatusShort }}
|
|
474
|
+
{{ releaseHandoff.featureBranchState.gitStatusShort | indent(2) }}
|
|
475
475
|
```
|
|
476
476
|
- {{ t("releaseHandoff.existingPrLabel") }}: `{{ releaseHandoff.featureBranchState.existingPrUrl or '(none)' }}`
|
|
477
477
|
|
|
@@ -542,7 +542,7 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
|
|
|
542
542
|
|
|
543
543
|
- Plan file (project-relative): `{{ implementation.approvedPlanReference.planFile }}`
|
|
544
544
|
- Approval evidence:
|
|
545
|
-
> {{ implementation.approvedPlanReference.approvalEvidence }}
|
|
545
|
+
> {{ implementation.approvedPlanReference.approvalEvidence | mdquote(2) }}
|
|
546
546
|
- {{ t("executionMeta.runExecutorWorktreePath") }}: `{{ implementation.approvedPlanReference.executorWorktreePath }}`
|
|
547
547
|
- {{ t("executionMeta.runBaseRef") }}: `{{ implementation.approvedPlanReference.baseRefSha }}`
|
|
548
548
|
|
|
@@ -587,11 +587,11 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
|
|
|
587
587
|
- Stage: `{{ implementation.stageSidecarEvidence.stageNumber }}` — {{ implementation.stageSidecarEvidence.stageTitle }}
|
|
588
588
|
- Carry sidecar JSON:
|
|
589
589
|
```json
|
|
590
|
-
{{ implementation.stageSidecarEvidence.carryJson }}
|
|
590
|
+
{{ implementation.stageSidecarEvidence.carryJson | indent(2) }}
|
|
591
591
|
```
|
|
592
592
|
- Appended `consumers.jsonl` rows:
|
|
593
593
|
{% for row in implementation.stageSidecarEvidence.consumerRows -%}
|
|
594
|
-
> {{ row }}
|
|
594
|
+
> {{ row | mdquote(2) }}
|
|
595
595
|
{% endfor %}
|
|
596
596
|
|
|
597
597
|
### 5.7.6 Validation Evidence
|
|
@@ -647,19 +647,19 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
|
|
|
647
647
|
|
|
648
648
|
- Path (project-relative): `{{ finalVerification.sourceImplementationReport.path }}`
|
|
649
649
|
- {{ t("evidenceMeta.commitListSummary") }}:
|
|
650
|
-
> {{ finalVerification.sourceImplementationReport.commitListQuote }}
|
|
650
|
+
> {{ finalVerification.sourceImplementationReport.commitListQuote | mdquote(2) }}
|
|
651
651
|
- {{ t("evidenceMeta.diffSummaryQuote") }}:
|
|
652
|
-
> {{ finalVerification.sourceImplementationReport.diffSummaryQuote }}
|
|
652
|
+
> {{ finalVerification.sourceImplementationReport.diffSummaryQuote | mdquote(2) }}
|
|
653
653
|
- {{ t("evidenceMeta.targetWorktreePath") }}: `{{ finalVerification.sourceImplementationReport.worktreePath }}`
|
|
654
654
|
- {{ t("evidenceMeta.implementationBaseRef") }}: `{{ finalVerification.sourceImplementationReport.implementationBaseRef }}`
|
|
655
655
|
- {{ t("evidenceMeta.capturedHeadSha") }}: `{{ finalVerification.sourceImplementationReport.capturedHeadSha }}`
|
|
656
656
|
- {{ t("evidenceMeta.gitStatusAtRunStart") }}:
|
|
657
657
|
```
|
|
658
|
-
{{ finalVerification.sourceImplementationReport.gitStatusShort }}
|
|
658
|
+
{{ finalVerification.sourceImplementationReport.gitStatusShort | indent(2) }}
|
|
659
659
|
```
|
|
660
660
|
- {{ t("evidenceMeta.gitDiffStatAtRunStart") }}:
|
|
661
661
|
```
|
|
662
|
-
{{ finalVerification.sourceImplementationReport.gitDiffStat }}
|
|
662
|
+
{{ finalVerification.sourceImplementationReport.gitDiffStat | indent(2) }}
|
|
663
663
|
```
|
|
664
664
|
{% if verificationScope in ['whole-task', 'single-stage'] %}- {{ t("finalVerification.verificationScope") }}: `{{ verificationScope }}`
|
|
665
665
|
{% endif %}{% if finalVerification.stageReports %}- {{ t("finalVerification.stageReportsLabel") }}:
|
|
@@ -101,6 +101,7 @@ Every worker prompt MUST begin with these anchor headers, in this exact order, b
|
|
|
101
101
|
6. `**Coding preflight pack:** <absolute-path>` — points to the installed runtime resource pack under `<OKSTRA_HOME>/prompts/coding-preflight`. Implementation executors and verifiers use this to read `overview.md`, `clean-code.md`, and routed language/framework/architecture resources. Other worker roles may ignore it.
|
|
102
102
|
7. `**Errors log path:** <absolute-path>` — run-level JSONL (see Error reporting above).
|
|
103
103
|
8. `**Errors sidecar path:** <absolute-path>` — per-worker JSON (see Error reporting above).
|
|
104
|
+
9. `**Read scope:** …` — restates the *Reading rules* allowlist inline. This file is anchor 5, a path the worker opens partway into its run; by then a host SessionStart hook or a global `CLAUDE.md` / `AGENTS.md` project-conditional has already pointed it at `graphify-out/`, skill catalogs, and similar non-okstra artifacts. Only the inline copy arrives before the worker's first tool call.
|
|
104
105
|
|
|
105
106
|
**Exception — reverify dispatches.** A Phase 5.5 re-verification prompt (its `**Prompt History Path:**` carries a `-reverify-r<N>-` segment) deliberately omits anchors 5 and 6 and adds a `**Model:**` line — lightweight mode reads neither this file nor the preflight pack. The two errors-path anchors are NOT omitted; that gate applies to reverify prompts unchanged. The reverify anchor set is owned by `prompts/lead/convergence.md` "Required reverify-prompt anchor headers".
|
|
106
107
|
|
|
@@ -15,9 +15,10 @@ Bash tool_use 명령을 phase deny-list 와 대조해 위반을 기계적으로
|
|
|
15
15
|
한계 1: claude-code 세션 jsonl 만 스캔한다. codex/antigravity lead·worker 는 동일 형식의
|
|
16
16
|
tool_use transcript 를 ~/.claude/projects 에 남기지 않으므로 이 스캐너의 사정권 밖이며,
|
|
17
17
|
해당 런타임의 금지 행위는 여전히 수동 감사에 의존한다.
|
|
18
|
-
한계 2: deny-list 는
|
|
19
|
-
등장하는 경우(예: `git commit -m "... npm
|
|
20
|
-
`cd <path> && <cmd>` 형태 때문에 명령
|
|
18
|
+
한계 2: deny-list 는 heredoc 본문을 걷어낸(strip_heredoc_bodies) 명령 문자열 전체를
|
|
19
|
+
검색하므로, 금지 토큰이 인자/메시지에 등장하는 경우(예: `git commit -m "... npm
|
|
20
|
+
publish ..."`)는 여전히 위반으로 잡힐 수 있다. `cd <path> && <cmd>` 형태 때문에 명령
|
|
21
|
+
head 앵커링은 불가하므로 이 잔여 오탐은 감수한다.
|
|
21
22
|
"""
|
|
22
23
|
from __future__ import annotations
|
|
23
24
|
|
|
@@ -47,6 +48,43 @@ _UNIVERSAL = [
|
|
|
47
48
|
]
|
|
48
49
|
_NON_HANDOFF_PUSH = ("git push", re.compile(r"\bgit\s+push\b"))
|
|
49
50
|
|
|
51
|
+
_HEREDOC_START = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def strip_heredoc_bodies(command: str) -> str:
|
|
55
|
+
"""heredoc 본문을 걷어낸 명령 문자열.
|
|
56
|
+
|
|
57
|
+
heredoc 안의 토큰은 실행되는 명령이 아니라 파일에 **기록되는 데이터**다.
|
|
58
|
+
implementation-planning 은 배포 명령을 서술하는 계획서를 산출물로 내고 그
|
|
59
|
+
문서를 `python3 - <<'PY'` / `cat > … <<EOF` 로 패치하므로, 본문을 그대로
|
|
60
|
+
스캔하면 정상 경로에서 매 run 오탐이 난다. heredoc 시작 줄과 종료 구분자는
|
|
61
|
+
남겨 실제 명령 위치의 토큰은 계속 잡는다.
|
|
62
|
+
|
|
63
|
+
종료 구분자가 뒤에 없으면 아무것도 걷어내지 않는다 — `python3 -c "1 << shift"`
|
|
64
|
+
같은 시프트 연산이 heredoc 으로 오인돼 뒤따르는 진짜 명령을 숨기지 않도록,
|
|
65
|
+
본문 제거는 짝이 맞는 구분자를 실제로 찾았을 때만 한다.
|
|
66
|
+
"""
|
|
67
|
+
lines = command.split("\n")
|
|
68
|
+
kept: list[str] = []
|
|
69
|
+
index = 0
|
|
70
|
+
while index < len(lines):
|
|
71
|
+
line = lines[index]
|
|
72
|
+
kept.append(line)
|
|
73
|
+
index += 1
|
|
74
|
+
start = _HEREDOC_START.search(line)
|
|
75
|
+
if not start:
|
|
76
|
+
continue
|
|
77
|
+
delimiter = start.group(2)
|
|
78
|
+
end = next(
|
|
79
|
+
(i for i in range(index, len(lines)) if lines[i].strip() == delimiter),
|
|
80
|
+
None,
|
|
81
|
+
)
|
|
82
|
+
if end is None:
|
|
83
|
+
continue
|
|
84
|
+
kept.append(lines[end])
|
|
85
|
+
index = end + 1
|
|
86
|
+
return "\n".join(kept)
|
|
87
|
+
|
|
50
88
|
|
|
51
89
|
def forbidden_patterns_for(task_type: str) -> list[tuple[str, re.Pattern]]:
|
|
52
90
|
"""task_type(=phase profile) 의 deny-list. 모든 phase 가 _UNIVERSAL 을 받고,
|
|
@@ -104,8 +142,9 @@ def _scan_one(
|
|
|
104
142
|
if block.get("type") != "tool_use" or block.get("name") != "Bash":
|
|
105
143
|
continue
|
|
106
144
|
command = (block.get("input") or {}).get("command") or ""
|
|
145
|
+
executed = strip_heredoc_bodies(command)
|
|
107
146
|
for label, pattern in patterns:
|
|
108
|
-
if pattern.search(
|
|
147
|
+
if pattern.search(executed):
|
|
109
148
|
hits.append((label, command))
|
|
110
149
|
break # one violation per command — most-specific pattern wins
|
|
111
150
|
return hits
|
|
@@ -12,10 +12,12 @@ from dataclasses import dataclass
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import List, Tuple
|
|
14
14
|
|
|
15
|
-
# scripts/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
|
|
16
|
+
# insert whichever exists so okstra_ctl is importable directly.
|
|
17
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
18
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
19
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
19
21
|
|
|
20
22
|
from okstra_ctl.md_table import split_pipe_row # noqa: E402
|
|
21
23
|
|
|
@@ -24,10 +24,10 @@ import re
|
|
|
24
24
|
import sys
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if str(
|
|
30
|
-
|
|
27
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
28
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
29
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
30
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
31
31
|
|
|
32
32
|
from okstra_ctl.clarification_items import ( # noqa: E402
|
|
33
33
|
parse_clarification_items,
|
|
@@ -11,13 +11,14 @@ import sys
|
|
|
11
11
|
from datetime import datetime, timezone
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
-
# Make the
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# Make the okstra packages importable however this validator is reached:
|
|
15
|
+
# ``scripts/`` next to the repo checkout, ``python/`` next to the installed
|
|
16
|
+
# copy under ~/.okstra/lib. The manifests advertise the bare script path, so
|
|
17
|
+
# it must self-bootstrap rather than rely on an inherited PYTHONPATH.
|
|
17
18
|
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
18
|
-
|
|
19
|
-
if
|
|
20
|
-
|
|
19
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
20
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
21
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
21
22
|
|
|
22
23
|
try:
|
|
23
24
|
from okstra_ctl.final_report_schema import (
|
|
@@ -17,10 +17,12 @@ import re
|
|
|
17
17
|
import sys
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
# scripts/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
|
|
21
|
+
# insert whichever exists so okstra_ctl is importable directly.
|
|
22
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
23
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
24
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
25
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
24
26
|
|
|
25
27
|
from okstra_ctl.md_table import split_pipe_row # noqa: E402
|
|
26
28
|
|
|
@@ -10,9 +10,10 @@ import sys
|
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
14
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
15
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
16
17
|
|
|
17
18
|
from okstra_ctl.work_categories import WORK_CATEGORIES # noqa: E402
|
|
18
19
|
from okstra_ctl.fanout import topological_order, CycleError # noqa: E402
|
|
@@ -12,10 +12,12 @@ import sys
|
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
-
# scripts/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
|
|
16
|
+
# insert whichever exists so okstra_ctl is importable directly.
|
|
17
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
18
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
19
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
19
21
|
|
|
20
22
|
from okstra_ctl.improvement_lenses import (
|
|
21
23
|
LENSES,
|
|
@@ -26,6 +26,18 @@ from dataclasses import dataclass, field
|
|
|
26
26
|
from datetime import datetime
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
|
|
29
|
+
# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
|
|
30
|
+
# insert whichever exists so okstra_ctl is importable directly.
|
|
31
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
32
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
33
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
34
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
35
|
+
|
|
36
|
+
from okstra_ctl.worker_heartbeat import ( # noqa: E402
|
|
37
|
+
HEARTBEAT_LINE_RE,
|
|
38
|
+
HEARTBEAT_MAX_GAP_SECONDS,
|
|
39
|
+
)
|
|
40
|
+
|
|
29
41
|
_DISPATCHED_STATUSES = {"completed", "timeout", "error", "in-progress"}
|
|
30
42
|
_WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
|
|
31
43
|
_ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
|
|
@@ -39,13 +51,10 @@ _PROGRESS_LINE_RE = re.compile(
|
|
|
39
51
|
re.MULTILINE,
|
|
40
52
|
)
|
|
41
53
|
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# 계약상 cadence 는 5분. append 직전 측정한 시각과 실제 쓰기 사이 지연을 흡수하는
|
|
47
|
-
# 고정 grace 60초를 더한다.
|
|
48
|
-
_HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60
|
|
54
|
+
# heartbeat 라인 shape 과 cadence 예산은 okstra_ctl.worker_heartbeat 정본을 쓴다 —
|
|
55
|
+
# `okstra worker-liveness` 가 run 도중 같은 판정을 내리므로 정의가 갈리면 안 된다.
|
|
56
|
+
_HEARTBEAT_LINE_RE = HEARTBEAT_LINE_RE
|
|
57
|
+
_HEARTBEAT_MAX_GAP_SECONDS = HEARTBEAT_MAX_GAP_SECONDS
|
|
49
58
|
|
|
50
59
|
# Phase 5/6 진입 전 lead 가 Read 해야 하는 implementation 프로파일 sidecar.
|
|
51
60
|
# 절대 경로는 레이어(repo / runtime / 설치본)마다 다르지만 basename 은 동일하다.
|
package/src/cli-registry.mjs
CHANGED
|
@@ -159,6 +159,13 @@ export const COMMAND_REGISTRY = [
|
|
|
159
159
|
category: "introspection",
|
|
160
160
|
summary: ["Inventory wrapper sidecar logs by size and task (read-only)"],
|
|
161
161
|
},
|
|
162
|
+
{
|
|
163
|
+
name: "worker-liveness",
|
|
164
|
+
module: "./commands/inspect/worker-liveness.mjs",
|
|
165
|
+
export: "run",
|
|
166
|
+
category: "introspection",
|
|
167
|
+
summary: ["Report whether pending workers are still alive (read-only)"],
|
|
168
|
+
},
|
|
162
169
|
{
|
|
163
170
|
name: "error-report",
|
|
164
171
|
module: "./commands/inspect/error-report.mjs",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { runPythonModule } from "../../lib/python-helper.mjs";
|
|
2
|
+
|
|
3
|
+
const USAGE = `okstra worker-liveness — report whether pending workers are still alive
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
okstra worker-liveness [--audit <path>]... [--prompt <path>]...
|
|
7
|
+
[--max-idle <seconds>] [--launch-grace <seconds>] [--json]
|
|
8
|
+
|
|
9
|
+
--audit a claude-worker audit sidecar; stale past the heartbeat cadence means
|
|
10
|
+
the in-process worker hung.
|
|
11
|
+
--prompt a CLI-wrapper prompt-history path; no sibling .log or .status.json
|
|
12
|
+
past the launch grace means the wrapper never started.
|
|
13
|
+
|
|
14
|
+
Output: JSON { ok, checkedAt, probes[], unhealthy[] }. Exit 1 when any worker is
|
|
15
|
+
stalled or did not launch, so a poll loop can branch on the exit code. Read-only:
|
|
16
|
+
it reports, it never kills or re-dispatches.
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
export async function run(args) {
|
|
20
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
21
|
+
process.stdout.write(USAGE);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const result = await runPythonModule({
|
|
26
|
+
module: "okstra_ctl.worker_liveness",
|
|
27
|
+
args,
|
|
28
|
+
stdio: "inherit-stdout",
|
|
29
|
+
});
|
|
30
|
+
return result.code;
|
|
31
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { checkProject } from "./check-project.mjs";
|
|
2
2
|
import { runEnsureInstalled } from "./install.mjs";
|
|
3
|
+
import { missingHelperScripts } from "../../lib/helper-scripts.mjs";
|
|
4
|
+
import { resolveInstalledScript } from "../../lib/python-helper.mjs";
|
|
5
|
+
import { resolvePaths } from "../../lib/paths.mjs";
|
|
3
6
|
|
|
4
7
|
const USAGE = `okstra preflight — one-call skill preflight (ensure-installed + check-project)
|
|
5
8
|
|
|
@@ -11,8 +14,9 @@ verifies the target project has .okstra/project.json. Prints one JSON object:
|
|
|
11
14
|
|
|
12
15
|
ok: true {ok, projectRoot, projectJsonPath, projectId}
|
|
13
16
|
ok: false {ok, stage, reason, ...}
|
|
14
|
-
stage: install |
|
|
15
|
-
|
|
17
|
+
stage: install | helper_scripts_missing | resolve |
|
|
18
|
+
project_json_missing | project_json_invalid |
|
|
19
|
+
python | parse
|
|
16
20
|
|
|
17
21
|
Options:
|
|
18
22
|
--runtime <name> Runtime whose assets must be present (e.g. claude-code);
|
|
@@ -48,6 +52,12 @@ function emit(payload) {
|
|
|
48
52
|
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
// A BLOCKING lead step calls these mid-run; resolving them here turns a stale
|
|
56
|
+
// install into an up-front failure instead of a dead end hours into a run.
|
|
57
|
+
async function findMissingHelperScripts() {
|
|
58
|
+
return missingHelperScripts(await resolvePaths(), resolveInstalledScript);
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
export async function run(args) {
|
|
52
62
|
if (args.includes("--help") || args.includes("-h")) {
|
|
53
63
|
process.stdout.write(USAGE);
|
|
@@ -76,6 +86,19 @@ export async function run(args) {
|
|
|
76
86
|
return installCode === 2 ? 2 : 1;
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
const missingHelpers = await findMissingHelperScripts();
|
|
90
|
+
if (missingHelpers.length > 0) {
|
|
91
|
+
emit({
|
|
92
|
+
ok: false,
|
|
93
|
+
stage: "helper_scripts_missing",
|
|
94
|
+
missingHelpers,
|
|
95
|
+
reason:
|
|
96
|
+
`${missingHelpers.length} helper script(s) a lead prompt can call are not ` +
|
|
97
|
+
"installed — run 'okstra install' to repair before starting a run.",
|
|
98
|
+
});
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
const { code, payload } = await checkProject({ cwd: opts.cwd });
|
|
80
103
|
emit(payload);
|
|
81
104
|
return code;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Single source of truth for the python helper scripts that `okstra <cmd>`
|
|
2
|
+
// subcommands front through runInstalledScript(). Preflight asserts every one
|
|
3
|
+
// of them resolves under ~/.okstra/bin so a stale install fails at the cheap
|
|
4
|
+
// environment gate instead of at the blocking lead step that calls it mid-run.
|
|
5
|
+
// A contract test keeps this list in step with the `scriptName:` literals in
|
|
6
|
+
// src/commands/**.
|
|
7
|
+
|
|
8
|
+
export const HELPER_SCRIPT_NAMES = Object.freeze([
|
|
9
|
+
"okstra-error-log.py",
|
|
10
|
+
"okstra-incremental-carry.py",
|
|
11
|
+
"okstra-incremental-scope.py",
|
|
12
|
+
"okstra-inject-report-index.py",
|
|
13
|
+
"okstra-render-final-report.py",
|
|
14
|
+
"okstra-render-report-views.py",
|
|
15
|
+
"okstra-spawn-followups.py",
|
|
16
|
+
"okstra-token-usage.py",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// `resolve` is the same resolver the wrappers call, so preflight cannot pass a
|
|
20
|
+
// script the dispatch path would then fail to find.
|
|
21
|
+
export function missingHelperScripts(paths, resolve) {
|
|
22
|
+
return HELPER_SCRIPT_NAMES.filter((name) => !resolve(paths, name));
|
|
23
|
+
}
|
|
@@ -4,7 +4,7 @@ import { join, resolve as resolvePath } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { buildPythonpath, resolvePaths } from "./paths.mjs";
|
|
6
6
|
|
|
7
|
-
function resolveInstalledScript(paths, scriptName) {
|
|
7
|
+
export function resolveInstalledScript(paths, scriptName) {
|
|
8
8
|
// Prefer the installed copy under ~/.okstra/bin (what production users run);
|
|
9
9
|
// fall back to the in-repo source when invoked from a checkout that has not
|
|
10
10
|
// been installed (dev / CI).
|