okstra 0.154.2 → 0.156.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 +1 -0
- package/docs/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-compact-reminder.sh +34 -0
- package/runtime/bin/okstra-render-final-report.py +2 -0
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/convergence.md +2 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -1
- package/runtime/prompts/lead/plan-body-verification.md +13 -2
- package/runtime/prompts/lead/team-contract.md +40 -14
- package/runtime/python/okstra_ctl/convergence.py +31 -0
- package/runtime/python/okstra_ctl/convergence_provenance.py +185 -0
- package/runtime/python/okstra_ctl/dispatch_state.py +6 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +30 -6
- package/runtime/python/okstra_ctl/render_final_report.py +29 -0
- package/runtime/python/okstra_ctl/report_finalize.py +22 -3
- package/runtime/python/okstra_ctl/stage_map.py +98 -1
- package/runtime/python/okstra_ctl/worker_liveness.py +113 -8
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +32 -17
- package/runtime/skills/okstra-run/SKILL.md +2 -0
- package/runtime/templates/reports/settings.template.json +11 -0
- package/runtime/validators/validate-run.py +185 -161
- package/runtime/validators/validate_session_conformance.py +57 -0
- package/src/cli-registry.mjs +7 -0
- package/src/commands/execute/plan-verify.mjs +44 -0
- package/src/lib/python-helper.mjs +37 -11
|
@@ -870,3 +870,32 @@ def render_to_file(
|
|
|
870
870
|
tmp.write_text(rendered, encoding="utf-8")
|
|
871
871
|
tmp.replace(output_path)
|
|
872
872
|
return len(rendered.encode("utf-8"))
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def snapshot_last_valid(data_path: Path) -> Path | None:
|
|
876
|
+
"""Keep the data.json that just rendered, as `<name>.last-valid`.
|
|
877
|
+
|
|
878
|
+
The gate blocks in this file are hand-edited between self-fix rounds, and a
|
|
879
|
+
write in the wrong shape destroys the previous round's verdicts. `.okstra`
|
|
880
|
+
is conventionally gitignored, so there is no version history to fall back
|
|
881
|
+
on — recovery has meant scraping the JSON back out of the last rendered
|
|
882
|
+
markdown. This snapshot is that fallback, replaced only after a render that
|
|
883
|
+
passed schema enforcement, so it is always a document that renders.
|
|
884
|
+
|
|
885
|
+
Called by the CLI entry point rather than `render_to_file`, so rendering a
|
|
886
|
+
data.json in place (a fixture, a dry run) never writes beside its input.
|
|
887
|
+
Failing to write it must never fail the render: the report is the
|
|
888
|
+
deliverable, the snapshot is a convenience.
|
|
889
|
+
"""
|
|
890
|
+
snapshot = data_path.with_name(data_path.name + ".last-valid")
|
|
891
|
+
try:
|
|
892
|
+
tmp = snapshot.with_suffix(snapshot.suffix + f".tmp.{os.getpid()}")
|
|
893
|
+
tmp.write_bytes(data_path.read_bytes())
|
|
894
|
+
tmp.replace(snapshot)
|
|
895
|
+
except OSError as exc:
|
|
896
|
+
print(
|
|
897
|
+
f"render-final-report: could not write {snapshot.name} ({exc})",
|
|
898
|
+
file=sys.stderr,
|
|
899
|
+
)
|
|
900
|
+
return None
|
|
901
|
+
return snapshot
|
|
@@ -110,11 +110,30 @@ def task_id(manifest: Mapping[str, Any]) -> str:
|
|
|
110
110
|
raise FinalizeError(f"cannot infer task id from taskKey: {task_key}")
|
|
111
111
|
|
|
112
112
|
|
|
113
|
-
def
|
|
113
|
+
def _category_seq(manifest: Mapping[str, Any], category: str) -> str:
|
|
114
114
|
seqs = manifest.get("runSequencesByCategory")
|
|
115
115
|
if not isinstance(seqs, Mapping):
|
|
116
116
|
raise FinalizeError("run manifest has no runSequencesByCategory object")
|
|
117
|
-
return require_string(seqs,
|
|
117
|
+
return require_string(seqs, category)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_seq(manifest: Mapping[str, Any]) -> str:
|
|
121
|
+
"""How many times this task type has run — what a lead event records."""
|
|
122
|
+
return _category_seq(manifest, "manifests")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def report_seq(manifest: Mapping[str, Any]) -> str:
|
|
126
|
+
"""The `reports` sequence — the one the final-report filename carries.
|
|
127
|
+
|
|
128
|
+
Categories advance independently (`paths.compute_run_paths`), so a run whose
|
|
129
|
+
predecessor produced no report reuses the free report slot and `run_seq`
|
|
130
|
+
outruns this one. This value reaches `render-report-views --seq`, which
|
|
131
|
+
stamps it into the HTML `runMeta` naming the exported
|
|
132
|
+
`user-response-<task-type>-<seq>.md`; `user_response.write_sidecar` derives
|
|
133
|
+
that same name from the report path, so anything but `reports` makes the two
|
|
134
|
+
answer paths write different files for one report.
|
|
135
|
+
"""
|
|
136
|
+
return _category_seq(manifest, "reports")
|
|
118
137
|
|
|
119
138
|
|
|
120
139
|
def task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
|
|
@@ -204,7 +223,7 @@ class FinalizeContext:
|
|
|
204
223
|
task_key=require_string(manifest, "taskKey"),
|
|
205
224
|
task_type=require_string(manifest, "taskType"),
|
|
206
225
|
task_group=task_group(manifest),
|
|
207
|
-
seq=
|
|
226
|
+
seq=report_seq(manifest),
|
|
208
227
|
final_status_path=resolve_optional_path(
|
|
209
228
|
project_root, manifest.get("finalStatusPath")
|
|
210
229
|
),
|
|
@@ -218,6 +218,103 @@ def parse_stage_map_file(path: Path) -> list[StageMapStage]:
|
|
|
218
218
|
return parse_stage_map_text(text, source_plan_path=str(resolved))
|
|
219
219
|
|
|
220
220
|
|
|
221
|
+
def _require_data_positive_int(
|
|
222
|
+
value: Any, field: str, row_number: int, source_plan_path: str,
|
|
223
|
+
) -> int:
|
|
224
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
225
|
+
raise StageMapError(
|
|
226
|
+
"stage_map",
|
|
227
|
+
f"structured Stage Map row {row_number} has invalid {field} {value!r}",
|
|
228
|
+
source_plan_path,
|
|
229
|
+
)
|
|
230
|
+
return value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _require_data_text(
|
|
234
|
+
value: Any, field: str, row_number: int, source_plan_path: str,
|
|
235
|
+
) -> str:
|
|
236
|
+
if not isinstance(value, str) or not value.strip():
|
|
237
|
+
raise StageMapError(
|
|
238
|
+
"stage_map",
|
|
239
|
+
f"structured Stage Map row {row_number} has invalid {field} {value!r}",
|
|
240
|
+
source_plan_path,
|
|
241
|
+
)
|
|
242
|
+
return value
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _parse_data_stage_map_row(
|
|
246
|
+
value: Any, row_number: int, source_plan_path: str,
|
|
247
|
+
) -> StageMapStage:
|
|
248
|
+
if not isinstance(value, dict):
|
|
249
|
+
raise StageMapError(
|
|
250
|
+
"stage_map",
|
|
251
|
+
f"structured Stage Map row {row_number} must be an object",
|
|
252
|
+
source_plan_path,
|
|
253
|
+
)
|
|
254
|
+
stage_number = _require_data_positive_int(
|
|
255
|
+
value.get("stage"), "stage", row_number, source_plan_path
|
|
256
|
+
)
|
|
257
|
+
step_count = _require_data_positive_int(
|
|
258
|
+
value.get("stepCount"), "stepCount", row_number, source_plan_path
|
|
259
|
+
)
|
|
260
|
+
title = _require_data_text(
|
|
261
|
+
value.get("title"), "title", row_number, source_plan_path
|
|
262
|
+
)
|
|
263
|
+
depends_on = _require_data_text(
|
|
264
|
+
value.get("dependsOn"), "dependsOn", row_number, source_plan_path
|
|
265
|
+
)
|
|
266
|
+
exit_summary = _require_data_text(
|
|
267
|
+
value.get("exitContractSummary"),
|
|
268
|
+
"exitContractSummary",
|
|
269
|
+
row_number,
|
|
270
|
+
source_plan_path,
|
|
271
|
+
)
|
|
272
|
+
return StageMapStage(
|
|
273
|
+
stage_number,
|
|
274
|
+
title,
|
|
275
|
+
_parse_depends_on(depends_on.strip(), row_number, source_plan_path),
|
|
276
|
+
step_count,
|
|
277
|
+
exit_summary,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _parse_schema_v2_stage_map(
|
|
282
|
+
data: dict[str, Any], source_plan_path: str,
|
|
283
|
+
) -> list[StageMapStage]:
|
|
284
|
+
planning = data.get("implementationPlanning")
|
|
285
|
+
stage_map = planning.get("stageMap") if isinstance(planning, dict) else None
|
|
286
|
+
if not isinstance(stage_map, list) or not stage_map:
|
|
287
|
+
raise StageMapError(
|
|
288
|
+
"stage_map",
|
|
289
|
+
"structured report requires a non-empty implementationPlanning.stageMap",
|
|
290
|
+
source_plan_path,
|
|
291
|
+
)
|
|
292
|
+
stages = [
|
|
293
|
+
_parse_data_stage_map_row(value, row_number, source_plan_path)
|
|
294
|
+
for row_number, value in enumerate(stage_map, start=1)
|
|
295
|
+
]
|
|
296
|
+
_validate_stage_numbers(stages, source_plan_path)
|
|
297
|
+
return stages
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _parse_stage_map_source(markdown_path: Path) -> list[StageMapStage]:
|
|
301
|
+
resolved = markdown_path.resolve()
|
|
302
|
+
data_path = resolved.with_suffix(".data.json")
|
|
303
|
+
if not data_path.exists():
|
|
304
|
+
return parse_stage_map_file(resolved)
|
|
305
|
+
try:
|
|
306
|
+
data = json.loads(data_path.read_text(encoding="utf-8"))
|
|
307
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
308
|
+
raise StageMapError("stage_map", str(exc), str(data_path)) from exc
|
|
309
|
+
if not isinstance(data, dict):
|
|
310
|
+
raise StageMapError(
|
|
311
|
+
"stage_map", "structured report must be an object", str(data_path)
|
|
312
|
+
)
|
|
313
|
+
if data.get("schemaVersion") != "2.0":
|
|
314
|
+
return parse_stage_map_file(resolved)
|
|
315
|
+
return _parse_schema_v2_stage_map(data, str(data_path))
|
|
316
|
+
|
|
317
|
+
|
|
221
318
|
def stage_map_records(stages: Iterable[StageMapStage]) -> list[dict[str, Any]]:
|
|
222
319
|
return [
|
|
223
320
|
{
|
|
@@ -256,7 +353,7 @@ def load_task_stage_map(
|
|
|
256
353
|
return StageMapSnapshot(
|
|
257
354
|
"ready",
|
|
258
355
|
str(resolved),
|
|
259
|
-
stage_map_records(
|
|
356
|
+
stage_map_records(_parse_stage_map_source(resolved)),
|
|
260
357
|
)
|
|
261
358
|
|
|
262
359
|
|
|
@@ -31,6 +31,8 @@ from __future__ import annotations
|
|
|
31
31
|
import argparse
|
|
32
32
|
import json
|
|
33
33
|
import sys
|
|
34
|
+
import time
|
|
35
|
+
from collections.abc import Callable
|
|
34
36
|
from dataclasses import dataclass
|
|
35
37
|
from datetime import datetime, timezone
|
|
36
38
|
from pathlib import Path
|
|
@@ -49,6 +51,12 @@ from okstra_ctl.worker_heartbeat import (
|
|
|
49
51
|
)
|
|
50
52
|
|
|
51
53
|
DEFAULT_LAUNCH_GRACE_SECONDS = 60
|
|
54
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 20.0
|
|
55
|
+
DEFAULT_WAIT_TIMEOUT_SECONDS = 2400.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _utc_now() -> datetime:
|
|
59
|
+
return datetime.now(timezone.utc)
|
|
52
60
|
|
|
53
61
|
|
|
54
62
|
def _log_path(prompt: Path) -> Path:
|
|
@@ -167,10 +175,12 @@ def probe_launch(
|
|
|
167
175
|
@dataclass(frozen=True)
|
|
168
176
|
class ProbeTarget:
|
|
169
177
|
"""One pending worker resolved from team-state: which artifact answers for
|
|
170
|
-
it, where that artifact is,
|
|
178
|
+
it, where that artifact is, when this dispatch started, and the result file
|
|
179
|
+
whose arrival means this worker is done."""
|
|
171
180
|
liveness_mode: str
|
|
172
181
|
artifact: Path
|
|
173
182
|
dispatched_at: datetime
|
|
183
|
+
result_path: Path | None = None
|
|
174
184
|
|
|
175
185
|
|
|
176
186
|
def probe_one(target: ProbeTarget, *, now: datetime, max_idle: float,
|
|
@@ -198,6 +208,61 @@ def probe_all(
|
|
|
198
208
|
"unhealthy": unhealthy}
|
|
199
209
|
|
|
200
210
|
|
|
211
|
+
def result_ready(target: ProbeTarget) -> bool:
|
|
212
|
+
"""Whether this worker's result file has landed with content in it."""
|
|
213
|
+
path = target.result_path
|
|
214
|
+
return bool(path and path.is_file() and path.stat().st_size > 0)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def wait_for_results(
|
|
218
|
+
targets: list[ProbeTarget],
|
|
219
|
+
*,
|
|
220
|
+
max_idle: float,
|
|
221
|
+
launch_grace: float,
|
|
222
|
+
interval: float,
|
|
223
|
+
timeout: float,
|
|
224
|
+
clock: Callable[[], datetime] = _utc_now,
|
|
225
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
226
|
+
) -> dict:
|
|
227
|
+
"""Poll until every result file lands, a worker dies, or the deadline passes.
|
|
228
|
+
|
|
229
|
+
This is the loop a lead would otherwise hand-write in Bash at each dispatch,
|
|
230
|
+
and every hand-written one has to re-derive the same two facts: what "done"
|
|
231
|
+
means (the persisted ``resultPath``, not a guessed filename) and what
|
|
232
|
+
"dead" means (`probe_all`, whose graces run from ``startedAt`` — never from
|
|
233
|
+
an artifact's mtime, which a re-dispatched worker inherits from its previous
|
|
234
|
+
attempt and reads as instantly stale).
|
|
235
|
+
|
|
236
|
+
``outcome`` is ``completed`` / ``unhealthy`` / ``timeout``.
|
|
237
|
+
"""
|
|
238
|
+
started = clock()
|
|
239
|
+
while True:
|
|
240
|
+
now = clock()
|
|
241
|
+
result = probe_all(
|
|
242
|
+
targets, now=now, max_idle=max_idle, launch_grace=launch_grace
|
|
243
|
+
)
|
|
244
|
+
pending = [
|
|
245
|
+
str(t.result_path) for t in targets if not result_ready(t)
|
|
246
|
+
]
|
|
247
|
+
waited = (now - started).total_seconds()
|
|
248
|
+
if not pending:
|
|
249
|
+
outcome = "completed"
|
|
250
|
+
elif result["unhealthy"]:
|
|
251
|
+
outcome = "unhealthy"
|
|
252
|
+
elif waited >= timeout:
|
|
253
|
+
outcome = "timeout"
|
|
254
|
+
else:
|
|
255
|
+
sleep(interval)
|
|
256
|
+
continue
|
|
257
|
+
return {
|
|
258
|
+
**result,
|
|
259
|
+
"ok": outcome == "completed",
|
|
260
|
+
"outcome": outcome,
|
|
261
|
+
"pending": pending,
|
|
262
|
+
"waitedSeconds": int(waited),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
201
266
|
def _parse_utc(value: object, label: str) -> datetime:
|
|
202
267
|
if not isinstance(value, str) or not value:
|
|
203
268
|
raise DispatchError(f"{label} must be a UTC ISO timestamp")
|
|
@@ -262,11 +327,18 @@ def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
|
|
|
262
327
|
artifact_value = worker.get(field)
|
|
263
328
|
if not isinstance(artifact_value, str) or not artifact_value.strip():
|
|
264
329
|
raise DispatchError(f"worker {worker_id} has no {field}")
|
|
330
|
+
project_root = _project_root_for_team_state(team_state_path)
|
|
265
331
|
artifact = Path(artifact_value)
|
|
266
332
|
if not artifact.is_absolute():
|
|
267
|
-
artifact =
|
|
333
|
+
artifact = project_root / artifact
|
|
268
334
|
dispatched_at = _parse_utc(worker.get("startedAt"), f"worker {worker_id} startedAt")
|
|
269
|
-
|
|
335
|
+
result_value = worker.get("resultPath")
|
|
336
|
+
result_path = None
|
|
337
|
+
if isinstance(result_value, str) and result_value.strip():
|
|
338
|
+
result_path = Path(result_value)
|
|
339
|
+
if not result_path.is_absolute():
|
|
340
|
+
result_path = project_root / result_path
|
|
341
|
+
return ProbeTarget(mode, artifact, dispatched_at, result_path)
|
|
270
342
|
|
|
271
343
|
|
|
272
344
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -283,6 +355,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
283
355
|
parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
|
|
284
356
|
help="seconds a worker may take to write its first artifact")
|
|
285
357
|
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
358
|
+
parser.add_argument(
|
|
359
|
+
"--wait", action="store_true",
|
|
360
|
+
help=(
|
|
361
|
+
"poll until every worker's persisted resultPath lands (exit 0), one "
|
|
362
|
+
"worker probes unhealthy (exit 1), or --timeout passes (exit 2). "
|
|
363
|
+
"Use this instead of hand-writing a Bash poll loop."
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
parser.add_argument("--interval", type=float, default=DEFAULT_POLL_INTERVAL_SECONDS,
|
|
367
|
+
help="--wait poll interval in seconds")
|
|
368
|
+
parser.add_argument("--timeout", type=float, default=DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
369
|
+
help="--wait deadline in seconds")
|
|
286
370
|
args = parser.parse_args(argv)
|
|
287
371
|
|
|
288
372
|
if len(args.team_state) != len(args.worker):
|
|
@@ -298,16 +382,37 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
298
382
|
except DispatchError as exc:
|
|
299
383
|
parser.error(str(exc))
|
|
300
384
|
|
|
301
|
-
|
|
385
|
+
if not args.wait:
|
|
386
|
+
result = probe_all(
|
|
387
|
+
targets,
|
|
388
|
+
now=_utc_now(),
|
|
389
|
+
max_idle=args.max_idle,
|
|
390
|
+
launch_grace=args.launch_grace,
|
|
391
|
+
)
|
|
392
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
393
|
+
# Non-zero on an unhealthy worker so a poll loop can branch on the exit
|
|
394
|
+
# code without parsing the JSON.
|
|
395
|
+
return 0 if result["ok"] else 1
|
|
396
|
+
|
|
397
|
+
unwaitable = [
|
|
398
|
+
worker for target, worker in zip(targets, args.worker, strict=True)
|
|
399
|
+
if target.result_path is None
|
|
400
|
+
]
|
|
401
|
+
if unwaitable:
|
|
402
|
+
parser.error(
|
|
403
|
+
f"--wait needs a resultPath in team-state for: {', '.join(unwaitable)}. "
|
|
404
|
+
"Waiting on a guessed filename is what --wait exists to prevent."
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
result = wait_for_results(
|
|
302
408
|
targets,
|
|
303
|
-
now=datetime.now(timezone.utc),
|
|
304
409
|
max_idle=args.max_idle,
|
|
305
410
|
launch_grace=args.launch_grace,
|
|
411
|
+
interval=args.interval,
|
|
412
|
+
timeout=args.timeout,
|
|
306
413
|
)
|
|
307
414
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
308
|
-
|
|
309
|
-
# without parsing the JSON.
|
|
310
|
-
return 0 if result["ok"] else 1
|
|
415
|
+
return {"completed": 0, "unhealthy": 1}.get(result["outcome"], 2)
|
|
311
416
|
|
|
312
417
|
|
|
313
418
|
if __name__ == "__main__":
|
|
@@ -187,10 +187,18 @@ def validate_reverify_prompt(
|
|
|
187
187
|
*,
|
|
188
188
|
task_type: str,
|
|
189
189
|
forbidden_actions: str,
|
|
190
|
+
expected_model: str | None = None,
|
|
190
191
|
) -> list[str]:
|
|
191
|
-
"""Require the active phase boundary in a lightweight reverify prompt.
|
|
192
|
+
"""Require the active phase boundary in a lightweight reverify prompt.
|
|
193
|
+
|
|
194
|
+
``expected_model`` is the value this dispatch will actually run. A reverify
|
|
195
|
+
prompt's `**Model:**` header is hand-written per round, and a header naming
|
|
196
|
+
a model the runtime does not serve does not fail here — it fails as a
|
|
197
|
+
provider 400 once the worker launches, where it reads as a worker fault.
|
|
198
|
+
Pass the dispatch's model so the mismatch is caught before launch.
|
|
199
|
+
"""
|
|
192
200
|
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
193
|
-
errors: list[str] =
|
|
201
|
+
errors: list[str] = _validate_model_header(normalized, expected_model)
|
|
194
202
|
task_values = _header_values(normalized, TASK_TYPE_HEADER)
|
|
195
203
|
if task_values != [task_type]:
|
|
196
204
|
errors.append(
|
|
@@ -287,23 +295,30 @@ def validate_initial_prompt_records(
|
|
|
287
295
|
return errors
|
|
288
296
|
|
|
289
297
|
|
|
290
|
-
def
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
298
|
+
def _validate_model_header(text: str, expected_model: str | None) -> list[str]:
|
|
299
|
+
"""The `**Model:** <label>, <model>` header must name the requested model.
|
|
300
|
+
|
|
301
|
+
A caller with no resolved model passes ``None`` and the header is not
|
|
302
|
+
judged — there is nothing to compare it against.
|
|
303
|
+
"""
|
|
304
|
+
if expected_model is None:
|
|
305
|
+
return []
|
|
297
306
|
model = _model_value(_header_values(text, MODEL_HEADER))
|
|
298
307
|
if model is None:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
return
|
|
308
|
+
return ["exactly one non-empty **Model:** <label>, <model> header is required"]
|
|
309
|
+
if model != expected_model:
|
|
310
|
+
return [f"prompt model does not match requested model: {expected_model}"]
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _validate_record_metadata(text: str, record: PromptRecord) -> list[str]:
|
|
315
|
+
return [
|
|
316
|
+
*_validate_delivery_mode(
|
|
317
|
+
_header_values(text, PROMPT_DELIVERY_MODE_HEADER),
|
|
318
|
+
record.expected_delivery_mode,
|
|
319
|
+
),
|
|
320
|
+
*_validate_model_header(text, record.expected_model),
|
|
321
|
+
]
|
|
307
322
|
|
|
308
323
|
|
|
309
324
|
def _validate_evidence_ledger_header(
|
|
@@ -193,6 +193,8 @@ If an action has an unknown `command`, `key`, or `scope`, stop and report the wi
|
|
|
193
193
|
|
|
194
194
|
Before rendering the next phase's bundle — and between worker rounds within a phase (reverify/critic/gapverify batches), after you have collected that round's results and token usage and before you dispatch the next round — reclaim the prior round's completed teammate panes so they do not accumulate, in two passes and adding `--keep report-writer-worker` to **both** whenever the report writer is still in flight. First source the count: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` never kills and prints one `<pane_id>\t<pane_title>` line per pane it would reclaim — count those lines as `<n>`. Then run the same command **without** `--list` to perform the reclaim, and emit `PROGRESS: phase-batch-cleanup panes=<n>` with that count at the batch boundary. Call both passes after collecting results and before the next dispatch so no in-flight worker pane is caught. This `tmux kill-pane`s the harness teammate panes; `shutdown_request` alone only idles the agent and never frees the pane, so it stays part of the run-end sequence for roster/token hygiene. `<RUN_DIR>` is the current (or just-finished) run's directory; its recorded `state/lead-pane.id` scopes the lead's session and the lead pane is never killed. In a non-tmux session there are no panes and the script is a silent no-op.
|
|
195
195
|
|
|
196
|
+
Before you ask the user for any approval, clarification, or decision after workers have been dispatched, run the same reclaim first: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` to count the panes, then the same command without `--list` to close them, emit `PROGRESS: phase-gate-cleanup panes=<n>`, and `TaskStop` each completed worker. A `TaskStop` by itself idles the task but leaves the pane open — the `trace-cleanup` call is what actually closes it. This keeps a user gate from being shown while finished worker panes remain; in-flight workers and an in-flight report writer are preserved.
|
|
197
|
+
|
|
196
198
|
Build the `okstra render-bundle` invocation from `outcome.renderArgs`, passing each key as `--<key>` and the value verbatim (including empty strings — they are intentional `use phase default` markers).
|
|
197
199
|
|
|
198
200
|
Analysis sidetracks therefore forward wizard-owned entries such as `--analysis-target "<args.analysis-target>"` and `--evidence-inputs "<args.evidence-inputs>"` when those keys are present. These are examples of the generic mapping rule, not a separate hard-coded argument list.
|
|
@@ -38,6 +38,17 @@
|
|
|
38
38
|
"filesystem": { "allowWrite": ["~/.gemini", "~/.codex"] }
|
|
39
39
|
},
|
|
40
40
|
"hooks": {
|
|
41
|
+
"SessionStart": [
|
|
42
|
+
{
|
|
43
|
+
"matcher": "compact",
|
|
44
|
+
"hooks": [
|
|
45
|
+
{
|
|
46
|
+
"type": "command",
|
|
47
|
+
"command": "$HOME/.okstra/bin/okstra-compact-reminder.sh"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
],
|
|
41
52
|
"PreToolUse": [
|
|
42
53
|
{
|
|
43
54
|
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
|