okstra 0.173.0 → 0.174.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/storage-model.md +13 -3
- package/docs/architecture.md +5 -21
- package/docs/cli.md +3 -2
- package/docs/container.md +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +13 -13
- package/docs/task-process/README.md +1 -1
- package/docs/task-process/implementation-planning.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -1
- package/runtime/bin/okstra-provider-exec.py +29 -12
- package/runtime/bin/okstra-trace-cleanup.sh +58 -129
- package/runtime/prompts/lead/adapters/cmux.md +2 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +3 -3
- package/runtime/prompts/lead/report-writer.md +6 -6
- package/runtime/prompts/profiles/_common-contract.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +2 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +12 -9
- package/runtime/prompts/profiles/implementation.md +2 -1
- package/runtime/prompts/profiles/release-handoff.md +1 -1
- package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
- package/runtime/python/okstra_ctl/conformance.py +68 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
- package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
- package/runtime/python/okstra_ctl/doctor.py +2 -2
- package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
- package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
- package/runtime/python/okstra_ctl/render_final_report.py +15 -19
- package/runtime/python/okstra_ctl/report_contract.py +0 -1
- package/runtime/python/okstra_ctl/report_finalize.py +68 -9
- package/runtime/python/okstra_ctl/run.py +43 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
- package/runtime/python/okstra_ctl/scope_provenance.py +1 -1
- package/runtime/python/okstra_ctl/session.py +69 -12
- package/runtime/python/okstra_ctl/team.py +51 -25
- package/runtime/python/okstra_ctl/tmux.py +19 -149
- package/runtime/python/okstra_ctl/worker_request.py +2 -0
- package/runtime/python/okstra_ctl/worktree.py +69 -3
- package/runtime/python/okstra_token_usage/cli.py +1 -1
- package/runtime/python/okstra_token_usage/collect.py +66 -6
- package/runtime/skills/okstra-setup/references/project-config.md +11 -0
- package/runtime/templates/reports/settings.template.json +0 -24
- package/runtime/validators/lib/fixtures.sh +49 -17
- package/runtime/validators/validate-implementation-plan-stages.py +63 -3
- package/runtime/validators/validate-run.py +14 -473
- package/runtime/validators/validate_session_conformance.py +1 -1
- package/src/cli-registry.mjs +8 -1
- package/src/commands/execute/team.mjs +3 -3
- package/src/commands/execute/worktree-status.mjs +109 -0
- package/src/commands/lifecycle/install.mjs +0 -2
- package/src/commands/report/finalize.mjs +13 -6
- package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
- package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
- package/runtime/templates/reports/final-report.template.md +0 -1258
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Mini JSON Schema validator for the final-report data.json.
|
|
2
2
|
|
|
3
3
|
This is a deliberately narrow JSON Schema implementation that supports
|
|
4
|
-
exactly the keywords used by ``schemas/final-report-
|
|
4
|
+
exactly the keywords used by ``schemas/final-report-v2.0.schema.json``:
|
|
5
5
|
|
|
6
6
|
type, required, properties, additionalProperties, enum, const, pattern,
|
|
7
7
|
minLength, minItems, maxItems, uniqueItems, items, minimum, maximum, $ref ($defs),
|
|
@@ -23,6 +23,8 @@ import re
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import Any
|
|
25
25
|
|
|
26
|
+
from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION
|
|
27
|
+
|
|
26
28
|
|
|
27
29
|
class SchemaError(ValueError):
|
|
28
30
|
"""Raised when a schema itself is malformed (e.g. a $ref points
|
|
@@ -260,7 +262,6 @@ def validate(data: Any, schema: dict) -> list[str]:
|
|
|
260
262
|
|
|
261
263
|
|
|
262
264
|
SCHEMA_FILENAMES = {
|
|
263
|
-
"1.0": "final-report-v1.0.schema.json",
|
|
264
265
|
"2.0": "final-report-v2.0.schema.json",
|
|
265
266
|
}
|
|
266
267
|
|
|
@@ -295,9 +296,9 @@ def load_schema_for_data(data: dict, start: Path | None = None) -> dict:
|
|
|
295
296
|
|
|
296
297
|
def load_schema(schema_path: Path | None = None) -> dict:
|
|
297
298
|
"""Load the final-report schema. If ``schema_path`` is None, locate
|
|
298
|
-
``schemas/final-report-
|
|
299
|
+
``schemas/final-report-v2.0.schema.json`` relative to this file's
|
|
299
300
|
repo root.
|
|
300
301
|
"""
|
|
301
302
|
if schema_path is not None:
|
|
302
303
|
return json.loads(Path(schema_path).read_text(encoding="utf-8"))
|
|
303
|
-
return load_schema_version(
|
|
304
|
+
return load_schema_version(CURRENT_REPORT_SCHEMA_VERSION)
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
1
|
+
"""활성 run 스코프 조회 — pane 정리 의무를 어느 run 에 걸지 고르는 데 쓴다.
|
|
2
|
+
|
|
3
|
+
호출자는 `SessionStart(compact)` 훅(`okstra-compact-reminder.sh`) 하나다. 압축
|
|
4
|
+
직후 리드에게 "이 run 의 완료 teammate pane 을 라운드 경계마다 회수하라"는 의무를
|
|
5
|
+
재주입할 때, 그 대상이 되는 이 프로젝트의 진행 중 run 을 여기서 찾는다.
|
|
6
|
+
|
|
7
|
+
완료 판정(`is_completed_status`)과 프로젝트 무관 전체 조회(`active_run_dirs`)도
|
|
8
|
+
여기 있었으나, 그것을 쓰던 `okstra-trace-cleanup.sh --reclaim-completed` 와
|
|
9
|
+
`okstra-subagent-reclaim.sh` 가 제거되면서 함께 사라졌다.
|
|
10
|
+
"""
|
|
3
11
|
from __future__ import annotations
|
|
4
12
|
|
|
5
13
|
import json
|
|
@@ -8,12 +16,6 @@ from pathlib import Path
|
|
|
8
16
|
|
|
9
17
|
from .paths import resolve_under_root
|
|
10
18
|
from .reconcile import NON_TERMINAL_RECENT_STATUSES
|
|
11
|
-
from .wrapper_status import read_wrapper_status
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def is_completed_status(status_path: str) -> bool:
|
|
15
|
-
status = read_wrapper_status(Path(status_path))
|
|
16
|
-
return status is not None and status.is_terminal
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
def _iter_active_run_dirs(home: Path):
|
|
@@ -30,9 +32,9 @@ def _iter_active_run_dirs(home: Path):
|
|
|
30
32
|
row = json.loads(line)
|
|
31
33
|
except json.JSONDecodeError:
|
|
32
34
|
continue
|
|
33
|
-
#
|
|
34
|
-
# SSOT. reserving 은 아직 run-dir 산출물이 없어
|
|
35
|
-
#
|
|
35
|
+
# 대상(진행 중) 집합은 reconcile 의 NON_TERMINAL_RECENT_STATUSES 가
|
|
36
|
+
# SSOT. reserving 은 아직 run-dir 산출물이 없어 이 집합에 들어있지
|
|
37
|
+
# 않으므로 자연히 제외된다(allowlist).
|
|
36
38
|
if row.get("status") not in NON_TERMINAL_RECENT_STATUSES:
|
|
37
39
|
continue
|
|
38
40
|
run_dir = resolve_under_root(row.get("projectRoot"), row.get("runDirRel"))
|
|
@@ -40,11 +42,6 @@ def _iter_active_run_dirs(home: Path):
|
|
|
40
42
|
yield row.get("projectRoot"), run_dir
|
|
41
43
|
|
|
42
44
|
|
|
43
|
-
def active_run_dirs(home: Path) -> list[Path]:
|
|
44
|
-
"""active.jsonl 의 진행 중 run 들의 절대 run-dir 목록."""
|
|
45
|
-
return [run_dir for _project_root, run_dir in _iter_active_run_dirs(home)]
|
|
46
|
-
|
|
47
|
-
|
|
48
45
|
def active_run_dirs_for_project(home: Path, cwd: Path) -> list[Path]:
|
|
49
46
|
"""진행 중 run 중 projectRoot 이 cwd(또는 그 상위)인 것의 절대 run-dir 목록.
|
|
50
47
|
SessionStart(compact) 훅이 이 프로젝트의 run 에만 리마인더를 걸도록 스코프한다."""
|
|
@@ -63,16 +60,10 @@ def active_run_dirs_for_project(home: Path, cwd: Path) -> list[Path]:
|
|
|
63
60
|
|
|
64
61
|
|
|
65
62
|
def main(argv: list[str]) -> int:
|
|
66
|
-
if len(argv) == 2 and argv[0] == "--active-dirs":
|
|
67
|
-
for run_dir in active_run_dirs(Path(argv[1])):
|
|
68
|
-
print(run_dir)
|
|
69
|
-
return 0
|
|
70
63
|
if len(argv) == 3 and argv[0] == "--active-dirs-for":
|
|
71
64
|
for run_dir in active_run_dirs_for_project(Path(argv[1]), Path(argv[2])):
|
|
72
65
|
print(run_dir)
|
|
73
66
|
return 0
|
|
74
|
-
if len(argv) == 1:
|
|
75
|
-
return 0 if is_completed_status(argv[0]) else 1
|
|
76
67
|
return 1
|
|
77
68
|
|
|
78
69
|
|
|
@@ -12,10 +12,10 @@ columns in the Execution Status table, omitted §4 phase-continuation
|
|
|
12
12
|
rows, ad-hoc ``## Index`` sections. Routing everything through one
|
|
13
13
|
template + schema cuts those failure modes to zero.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
Rendering never injects a reader index: the AI-handoff markdown keeps its
|
|
16
|
+
compact fixed order and the task-specific HTML provides human navigation.
|
|
17
|
+
``_inject_index_and_anchors`` survives as a standalone pass over an already
|
|
18
|
+
written markdown file, driven by ``scripts/okstra-inject-report-index.py``.
|
|
19
19
|
|
|
20
20
|
Phase 7 mutation flow: ``okstra-token-usage.py --substitute-data`` fills
|
|
21
21
|
the ``tokenUsage`` and ``executionStatus[].totalTokens`` etc. cells in
|
|
@@ -53,7 +53,11 @@ from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_ji
|
|
|
53
53
|
from okstra_ctl.md_table import UNESCAPED_PIPE_RE, to_cell_text
|
|
54
54
|
from okstra_ctl.models import UnknownModelError, resolve_model_metadata
|
|
55
55
|
from okstra_ctl.paths import find_asset_root
|
|
56
|
-
from okstra_ctl.report_contract import
|
|
56
|
+
from okstra_ctl.report_contract import (
|
|
57
|
+
CURRENT_REPORT_SCHEMA_VERSION,
|
|
58
|
+
TASK_TYPE_DATA_PROPERTY,
|
|
59
|
+
markdown_template_for,
|
|
60
|
+
)
|
|
57
61
|
from okstra_ctl.report_markdown import ReportSections
|
|
58
62
|
from okstra_ctl.schema_excerpt import excerpt_cut_from_version
|
|
59
63
|
from okstra_ctl.seeding import installed_version
|
|
@@ -61,10 +65,9 @@ from okstra_ctl.usage_cells import format_duration_ms, format_int, format_usd
|
|
|
61
65
|
|
|
62
66
|
|
|
63
67
|
TEMPLATE_BY_SCHEMA_VERSION = {
|
|
64
|
-
"1.0": ("templates", "reports", "final-report.template.md"),
|
|
65
68
|
"2.0": ("templates", "reports", "final-report-v2.template.md"),
|
|
66
69
|
}
|
|
67
|
-
DEFAULT_TEMPLATE_REL = TEMPLATE_BY_SCHEMA_VERSION[
|
|
70
|
+
DEFAULT_TEMPLATE_REL = TEMPLATE_BY_SCHEMA_VERSION[CURRENT_REPORT_SCHEMA_VERSION]
|
|
68
71
|
|
|
69
72
|
TASK_DELIVERABLE_TITLES = {
|
|
70
73
|
"requirements-discovery": "Requirements Discovery",
|
|
@@ -666,14 +669,7 @@ def render(
|
|
|
666
669
|
|
|
667
670
|
try:
|
|
668
671
|
template = env.get_template(template_path.name)
|
|
669
|
-
|
|
670
|
-
_ai_markdown_context(data, schema)
|
|
671
|
-
if data.get("schemaVersion") == "2.0"
|
|
672
|
-
else _with_optional_defaults(data)
|
|
673
|
-
)
|
|
674
|
-
rendered = template.render(**context)
|
|
675
|
-
if data.get("schemaVersion") == "1.0":
|
|
676
|
-
rendered = _inject_index_and_anchors(rendered, dictionary)
|
|
672
|
+
rendered = template.render(**_ai_markdown_context(data, schema))
|
|
677
673
|
return _ventilate_prose(rendered)
|
|
678
674
|
except I18nError as exc:
|
|
679
675
|
raise FinalReportRenderError(
|
|
@@ -689,10 +685,10 @@ def find_default_template(start: Path | None = None) -> Path:
|
|
|
689
685
|
"""Locate the bundled final-report template.
|
|
690
686
|
|
|
691
687
|
Resolution order:
|
|
692
|
-
1. ``$OKSTRA_HOME/templates/reports/final-report.template.md`` (installed runtime).
|
|
693
|
-
2. ``<repo>/templates/reports/final-report.template.md`` (in-repo dev runs).
|
|
688
|
+
1. ``$OKSTRA_HOME/templates/reports/final-report-v2.template.md`` (installed runtime).
|
|
689
|
+
2. ``<repo>/templates/reports/final-report-v2.template.md`` (in-repo dev runs).
|
|
694
690
|
Repo root is detected by walking up from this file until a
|
|
695
|
-
``templates/reports/final-report.template.md`` exists.
|
|
691
|
+
``templates/reports/final-report-v2.template.md`` exists.
|
|
696
692
|
|
|
697
693
|
Raises ``FinalReportRenderError`` if neither path is present.
|
|
698
694
|
"""
|
|
@@ -701,7 +697,7 @@ def find_default_template(start: Path | None = None) -> Path:
|
|
|
701
697
|
return root.joinpath(*DEFAULT_TEMPLATE_REL)
|
|
702
698
|
|
|
703
699
|
raise FinalReportRenderError(
|
|
704
|
-
"could not locate final-report.template.md. Set OKSTRA_HOME or "
|
|
700
|
+
"could not locate final-report-v2.template.md. Set OKSTRA_HOME or "
|
|
705
701
|
"run from a checkout that contains templates/reports/."
|
|
706
702
|
)
|
|
707
703
|
|
|
@@ -6,6 +6,18 @@ substitution, html view rendering, follow-up task spawning, and run validation.
|
|
|
6
6
|
The order is load-bearing — rendering before substitution ships `--` token
|
|
7
7
|
cells, and validating before rendering trips the report-views contract.
|
|
8
8
|
|
|
9
|
+
`token-usage` is the one step whose failure does not stop the sequence, which
|
|
10
|
+
deliberately accepts that first state: its input is the lead session log, so a
|
|
11
|
+
refusal there would otherwise delete every artifact the later steps produce.
|
|
12
|
+
The run does not pass in that state. `validators/validate-run.py` re-collects
|
|
13
|
+
when the recorded usage is all zeros against an `unavailable` session source
|
|
14
|
+
(`_needs_token_autofix`) and refuses the run rather than ship zeroed counts
|
|
15
|
+
(`accuracy-failed`); a legacy v1 report is caught earlier still, by its
|
|
16
|
+
unsubstituted `{{...}}` placeholders, which a v2 report never carries because
|
|
17
|
+
its numeric cells are `null` until this step fills them. Substituting the
|
|
18
|
+
tokens on a later retry then leaves the already-rendered html stale for
|
|
19
|
+
`validators/validate-report-views.py`.
|
|
20
|
+
|
|
9
21
|
The translation sidecar is NOT one of these steps. `render-views` overlays it,
|
|
10
22
|
so a non-English run dispatches the translator before this sequence starts —
|
|
11
23
|
after verifying the data.json is English, which is why `check-source` is also
|
|
@@ -29,6 +41,7 @@ from .agent_activity import ActivityProjectionError, project_agent_activity
|
|
|
29
41
|
from .dispatch_state import DispatchError, link_agent_dispatch_result
|
|
30
42
|
from .final_report_paths import final_report_data_path, final_report_markdown_path
|
|
31
43
|
from .paths import task_dir, task_manifest_file
|
|
44
|
+
from .session import observe_lead_session
|
|
32
45
|
|
|
33
46
|
|
|
34
47
|
STEP_PROJECT_ACTIVITY = "project-activity"
|
|
@@ -354,7 +367,14 @@ def run_finalize(
|
|
|
354
367
|
before_step: Callable[[str], None] | None = None,
|
|
355
368
|
only: Sequence[str] | None = None,
|
|
356
369
|
) -> dict[str, Any]:
|
|
357
|
-
"""Run the Phase 7 steps in order
|
|
370
|
+
"""Run the Phase 7 steps in contractual order.
|
|
371
|
+
|
|
372
|
+
A non-zero exit stops the sequence, with one exception: ``token-usage``
|
|
373
|
+
defers. Its input is the lead session log — state outside this run — so a
|
|
374
|
+
refusal there is not evidence that the artifacts after it are unwritable.
|
|
375
|
+
The deferred failure becomes the result's ``reason`` when nothing later
|
|
376
|
+
fails, and yields to any later failure, which is the step that actually
|
|
377
|
+
blocked the run.
|
|
358
378
|
|
|
359
379
|
``before_step`` fires immediately before each step is spawned, letting an
|
|
360
380
|
adapter settle state the step will read (the Codex adapter marks the report
|
|
@@ -366,6 +386,7 @@ def run_finalize(
|
|
|
366
386
|
step at full token and wall-clock cost.
|
|
367
387
|
"""
|
|
368
388
|
steps: list[dict[str, Any]] = []
|
|
389
|
+
deferred = ""
|
|
369
390
|
try:
|
|
370
391
|
commands = build_commands(ctx)
|
|
371
392
|
except FinalizeError as exc:
|
|
@@ -426,11 +447,21 @@ def run_finalize(
|
|
|
426
447
|
)
|
|
427
448
|
steps.append(step_payload(name, command, result))
|
|
428
449
|
if result.returncode != 0:
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
450
|
+
failure = f"{name} failed with exit code {result.returncode}"
|
|
451
|
+
# Token collection reads the lead session log, which lives outside
|
|
452
|
+
# this run. One refusal there (`grandTotalTokens=0`) took the whole
|
|
453
|
+
# run's output with it: html never rendered, follow-ups never
|
|
454
|
+
# spawned, and `validate-run` then blocked on `report-views:
|
|
455
|
+
# missing html artifact`. So this step alone defers instead of
|
|
456
|
+
# stopping the sequence. It hides nothing — the non-zero exit stays
|
|
457
|
+
# in `steps`, `ok` stays False, and the closing `validate-run`
|
|
458
|
+
# still refuses the run.
|
|
459
|
+
if name == STEP_TOKEN_USAGE:
|
|
460
|
+
deferred = failure
|
|
461
|
+
continue
|
|
462
|
+
return {"ok": False, "reason": failure, "steps": steps}
|
|
463
|
+
if deferred:
|
|
464
|
+
return {"ok": False, "reason": deferred, "steps": steps}
|
|
434
465
|
return {"ok": True, "reason": "", "steps": steps}
|
|
435
466
|
|
|
436
467
|
|
|
@@ -546,6 +577,29 @@ def _step_summary_lines(result: Mapping[str, Any]) -> list[str]:
|
|
|
546
577
|
return lines
|
|
547
578
|
|
|
548
579
|
|
|
580
|
+
def _recovery_step_names(result: Mapping[str, Any]) -> list[str]:
|
|
581
|
+
"""The steps a retry has to re-run: every step from the earliest failure on.
|
|
582
|
+
|
|
583
|
+
Naming only the failed steps would prescribe half a recovery. `token-usage`
|
|
584
|
+
defers, so `render-views` already wrote an html view from unsubstituted
|
|
585
|
+
data; substituting the tokens on a retry leaves that view stale
|
|
586
|
+
(`validators/validate-report-views.py` checks `source-sha256` against the
|
|
587
|
+
md body). And a sequence that stopped early never reached `validate-run`,
|
|
588
|
+
which is the step that decides whether the run is shippable. Resuming from
|
|
589
|
+
the earliest failure redoes both while still skipping the prefix that
|
|
590
|
+
succeeded — the saving `--only` exists for.
|
|
591
|
+
"""
|
|
592
|
+
failed = {
|
|
593
|
+
string_value(step.get("name"))
|
|
594
|
+
for step in (result.get("steps") or [])
|
|
595
|
+
if step.get("exitCode") != 0
|
|
596
|
+
}
|
|
597
|
+
for index, name in enumerate(STEP_ORDER):
|
|
598
|
+
if name in failed:
|
|
599
|
+
return list(STEP_ORDER[index:])
|
|
600
|
+
return []
|
|
601
|
+
|
|
602
|
+
|
|
549
603
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
550
604
|
args = _parser().parse_args(argv)
|
|
551
605
|
try:
|
|
@@ -553,6 +607,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
553
607
|
except FinalizeError as exc:
|
|
554
608
|
print(f"error: {exc}", file=sys.stderr)
|
|
555
609
|
return 2
|
|
610
|
+
# Phase 7 is the last boundary the lead crosses, and the last chance to add
|
|
611
|
+
# the generations resume and compaction split it into. It runs ahead of the
|
|
612
|
+
# sequence because the token-usage step below reads `leadSessionIds`.
|
|
613
|
+
observe_lead_session(ctx.project_root, ctx.team_state_path)
|
|
556
614
|
result = run_finalize(ctx, only=args.only or None)
|
|
557
615
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
558
616
|
print("finalize steps:", file=sys.stderr)
|
|
@@ -560,10 +618,11 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
560
618
|
print(line, file=sys.stderr)
|
|
561
619
|
if not result["ok"]:
|
|
562
620
|
print(f"error: {result['reason']}", file=sys.stderr)
|
|
563
|
-
|
|
621
|
+
recovery = _recovery_step_names(result)
|
|
622
|
+
if recovery:
|
|
623
|
+
flags = " ".join(f"--only {name}" for name in recovery)
|
|
564
624
|
print(
|
|
565
|
-
"
|
|
566
|
-
f"`--only {result['steps'][-1].get('name')}`",
|
|
625
|
+
f"resume the sequence from the earliest failure with `{flags}`",
|
|
567
626
|
file=sys.stderr,
|
|
568
627
|
)
|
|
569
628
|
return 1
|
|
@@ -125,7 +125,11 @@ from .render import (
|
|
|
125
125
|
)
|
|
126
126
|
from okstra_project.dirs import okstra_home
|
|
127
127
|
|
|
128
|
-
from .dispatch_state import
|
|
128
|
+
from .dispatch_state import (
|
|
129
|
+
BACKEND_CMUX_PANE,
|
|
130
|
+
detect_terminal_backend,
|
|
131
|
+
generate_claude_session_id,
|
|
132
|
+
)
|
|
129
133
|
from .run_context import (
|
|
130
134
|
compute_and_write_run_context,
|
|
131
135
|
refresh_run_context_snapshot,
|
|
@@ -139,7 +143,6 @@ from .seeding import (
|
|
|
139
143
|
verify_installation,
|
|
140
144
|
)
|
|
141
145
|
from .session import (
|
|
142
|
-
generate_claude_session_id,
|
|
143
146
|
resolve_inproc_lead_session_id,
|
|
144
147
|
write_claude_resume_command_file,
|
|
145
148
|
)
|
|
@@ -296,6 +299,43 @@ def _validate_data_json_approval_consistency(
|
|
|
296
299
|
)
|
|
297
300
|
|
|
298
301
|
|
|
302
|
+
def _validate_approved_plan_conformance(path: Path) -> None:
|
|
303
|
+
"""승인 계획의 stage conformance 선언 형식을 승인 경계에서 판정한다.
|
|
304
|
+
|
|
305
|
+
같은 판정이 지금까지는 구현 런의 마지막 `validate-run` 에서만 나왔다. 그때는
|
|
306
|
+
워커 배치와 수렴이 이미 끝난 뒤라 런 하나를 통째로 버려야 했다 — 실측된
|
|
307
|
+
실패에서 승인 계획의 stage 2~7 이 기계 형식이 아니라 산문이었다.
|
|
308
|
+
|
|
309
|
+
선언이 없는 stage 는 건드리지 않는다(`conformance.malformed_conformance_stages`
|
|
310
|
+
참조): XOR 부재는 계획 단계의 check S11 이 이미 막고, 구현 진입 게이트도
|
|
311
|
+
validate-run 이 계속 요구한다. 여기서 다시 하면 같은 규칙이 두 곳이 된다.
|
|
312
|
+
"""
|
|
313
|
+
from .conformance import malformed_conformance_stages
|
|
314
|
+
|
|
315
|
+
loaded = _load_final_report_data_if_present(path)
|
|
316
|
+
if loaded is None:
|
|
317
|
+
return
|
|
318
|
+
data_path, data = loaded
|
|
319
|
+
bad = malformed_conformance_stages(data)
|
|
320
|
+
if not bad:
|
|
321
|
+
return
|
|
322
|
+
# 한 stage 씩 고치고 다시 막히는 왕복을 피하려면 전부 나열해야 한다 —
|
|
323
|
+
# 실패한 런에서 검증기는 stage 2 만 지목했지만 실제로는 2~7 전부였다.
|
|
324
|
+
stages = ", ".join(str(number) for number in bad)
|
|
325
|
+
raise PrepareError(
|
|
326
|
+
f"approved plan data.json has malformed conformanceTests for stage(s) "
|
|
327
|
+
f"{stages}: {data_path}\n"
|
|
328
|
+
" each declaring stage must read "
|
|
329
|
+
"`<task_root>/qa/scripts/stage-<N>.<ext> "
|
|
330
|
+
"(requires=[db|io|http|external,...])` after the "
|
|
331
|
+
"`Conformance tests: stage-<N> — ` prefix "
|
|
332
|
+
"(prompts/profiles/implementation-planning.md), or carry "
|
|
333
|
+
"`Conformance exemption: <reason>` instead.\n"
|
|
334
|
+
" re-run implementation-planning so the declaration is regenerated in "
|
|
335
|
+
"that form, then approve it."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
299
339
|
def _set_data_json_approved_true_if_present(path: Path) -> bool:
|
|
300
340
|
loaded = _load_final_report_data_if_present(path)
|
|
301
341
|
if loaded is None:
|
|
@@ -474,6 +514,7 @@ def _validate_approved_plan(path: str) -> None:
|
|
|
474
514
|
)
|
|
475
515
|
_reject_blocking_plan_body_gate(p, body, action="approved plan validation")
|
|
476
516
|
_validate_data_json_approval_consistency(p, markdown_approved=True)
|
|
517
|
+
_validate_approved_plan_conformance(p)
|
|
477
518
|
# frontmatter approved == true 상태. §1 Clarification Items 의
|
|
478
519
|
# Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
|
|
479
520
|
scan = scan_approval_gate(p)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Build a task-type-scoped excerpt of the final-report schema.
|
|
2
2
|
|
|
3
|
-
The full schema (``schemas/final-report-
|
|
3
|
+
The full schema (``schemas/final-report-v2.0.schema.json``) carries the
|
|
4
4
|
deliverable property blocks for ALL task-types (``errorAnalysis``, the three
|
|
5
5
|
read-only analysis blocks, ``implementationPlanning``, ``releaseHandoff``,
|
|
6
6
|
``implementation``, and ``finalVerification``) plus a
|
|
@@ -21,7 +21,7 @@ CONTRACT_RULES = frozenset(
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
# Mirrors the report schema's requirement id pattern
|
|
24
|
-
# (schemas/final-report-
|
|
24
|
+
# (schemas/final-report-v2.0.schema.json: `^R-\d{3,}$`).
|
|
25
25
|
_REQ_ID_RE = re.compile(r"^R-\d{3,}$")
|
|
26
26
|
_BRIEF_RE = re.compile(r"^brief:\s*(?P<heading>.+?)\s*$")
|
|
27
27
|
_DERIVED_RE = re.compile(r"^derived:\s*(?P<parent>\S+)\s*[—-]\s*(?P<reason>.+?)\s*$")
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
"""Claude session helpers.
|
|
2
2
|
|
|
3
|
-
bash session.sh 의 python 구현. claude
|
|
4
|
-
파일 작성.
|
|
3
|
+
bash session.sh 의 python 구현. lead claude 세션 관측, resume command
|
|
4
|
+
파일 작성. 세션 id 발급기는 워커 dispatch 도 쓰므로
|
|
5
|
+
`dispatch_state.generate_claude_session_id` 에 있다.
|
|
5
6
|
"""
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
10
|
-
import uuid
|
|
11
11
|
from pathlib import Path
|
|
12
|
+
from typing import Collection
|
|
12
13
|
|
|
13
|
-
from .dispatch_state import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
from .dispatch_state import (
|
|
15
|
+
DispatchError,
|
|
16
|
+
load_json_object,
|
|
17
|
+
mutate_team_state,
|
|
18
|
+
worker_session_ids,
|
|
19
|
+
)
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def _claude_projects_dir_for(cwd: Path) -> Path:
|
|
@@ -76,7 +77,11 @@ def _session_mentions(jsonl_path: Path, needle: str) -> bool:
|
|
|
76
77
|
return False
|
|
77
78
|
|
|
78
79
|
|
|
79
|
-
def resolve_lead_session_id_for_run(
|
|
80
|
+
def resolve_lead_session_id_for_run(
|
|
81
|
+
project_root: Path,
|
|
82
|
+
run_dir: Path,
|
|
83
|
+
exclude_session_ids: Collection[str] = (),
|
|
84
|
+
) -> str:
|
|
80
85
|
"""`run_dir` 를 다룬 lead 세션 중 가장 최근 수정된 것의 id, 없으면 ''.
|
|
81
86
|
|
|
82
87
|
프로젝트 디렉토리의 최신 jsonl 을 그대로 집으면(``resolve_inproc_lead_session_id``)
|
|
@@ -84,6 +89,12 @@ def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
|
|
|
84
89
|
(dev-10172): 남의 세션이 `leadSessionIds` 에 들어가 run 비용이 $80 → $207 로
|
|
85
90
|
부풀고, 그 세션의 PROGRESS 라인이 conformance 스캔에 섞여 오탐을 냈다.
|
|
86
91
|
자기 run 의 산출물 경로를 언급한 세션만 후보로 인정해 그 오염을 막는다.
|
|
92
|
+
|
|
93
|
+
`exclude_session_ids` 는 같은 run **안**의 오염을 막는다. cmux 워커는
|
|
94
|
+
`agentName` 을 안 남겨 아래 필터를 통과하고, 워커 세션도 자기 run 의 산출물
|
|
95
|
+
경로를 언급하므로 needle 에도 걸린다. 워커가 리드보다 늦게 끝나면 mtime
|
|
96
|
+
정렬에서 먼저 잡혀 워커 세션이 리드로 기록된다 — dispatch 가 발급한 id 를
|
|
97
|
+
호출자가 넘겨 그 세션들을 후보에서 뺀다.
|
|
87
98
|
"""
|
|
88
99
|
proj_dir = _claude_projects_dir_for(project_root)
|
|
89
100
|
try:
|
|
@@ -91,7 +102,10 @@ def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
|
|
|
91
102
|
except OSError:
|
|
92
103
|
return ""
|
|
93
104
|
needle = str(run_dir)
|
|
105
|
+
excluded = set(exclude_session_ids)
|
|
94
106
|
for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
|
|
107
|
+
if path.stem in excluded:
|
|
108
|
+
continue
|
|
95
109
|
if _session_has_agent_name(path):
|
|
96
110
|
continue
|
|
97
111
|
if _session_mentions(path, needle):
|
|
@@ -103,17 +117,36 @@ def record_observed_lead_session(project_root: Path, team_state_path: Path) -> s
|
|
|
103
117
|
"""이 run 의 live lead 세션을 관측해 team-state 에 append(멱등). 이 run 을
|
|
104
118
|
다룬 lead 세션을 못 찾거나 중복이면 '' 반환. 재발급으로 갈린 세대를 축1 이
|
|
105
119
|
여기에 모은다.
|
|
120
|
+
|
|
121
|
+
후보에서 뺄 워커 세션 id 는 append 대상인 team-state 자체가 들고 있다 —
|
|
122
|
+
dispatch 가 `workerDispatches[].sessionId` 에 적어 둔 값이라, 관측 시점에
|
|
123
|
+
이 run 이 연 워커 세션의 목록은 그 파일 하나로 완결된다.
|
|
106
124
|
"""
|
|
107
|
-
|
|
125
|
+
try:
|
|
126
|
+
team_state = load_json_object(team_state_path, "team-state")
|
|
127
|
+
except (DispatchError, OSError):
|
|
128
|
+
team_state = {}
|
|
129
|
+
sid = resolve_lead_session_id_for_run(
|
|
130
|
+
project_root,
|
|
131
|
+
team_state_path.parent.parent,
|
|
132
|
+
exclude_session_ids=worker_session_ids(team_state),
|
|
133
|
+
)
|
|
108
134
|
if not sid:
|
|
109
135
|
return ""
|
|
110
136
|
def add_observed_session(state: dict) -> bool:
|
|
111
137
|
lead_ids = state.setdefault("leadSessionIds", [])
|
|
138
|
+
observed = state.setdefault("observedTeamNames", [])
|
|
139
|
+
# 두 키가 list 가 아니면 `sid in None` 이 TypeError 를, `str.append` 가
|
|
140
|
+
# AttributeError 를 낸다. 그 예외는 아래 `(DispatchError, OSError)` 도
|
|
141
|
+
# `observe_lead_session` 의 `OSError` 도 통과해 dispatch 를 죽인다 —
|
|
142
|
+
# 관측이 호출자를 깨뜨리지 않는다는 계약을 docstring 이 아니라 여기서
|
|
143
|
+
# 지킨다. 관측을 건너뛰면 이 run 은 관측 이전과 같은 상태로 남는다.
|
|
144
|
+
if not isinstance(lead_ids, list) or not isinstance(observed, list):
|
|
145
|
+
return False
|
|
112
146
|
if sid in lead_ids:
|
|
113
147
|
return False
|
|
114
148
|
lead_ids.append(sid)
|
|
115
149
|
team = f"session-{sid[:8]}"
|
|
116
|
-
observed = state.setdefault("observedTeamNames", [])
|
|
117
150
|
if team not in observed:
|
|
118
151
|
observed.append(team)
|
|
119
152
|
return True
|
|
@@ -125,6 +158,30 @@ def record_observed_lead_session(project_root: Path, team_state_path: Path) -> s
|
|
|
125
158
|
return sid if changed else ""
|
|
126
159
|
|
|
127
160
|
|
|
161
|
+
def observe_lead_session(project_root: Path, team_state_path: Path) -> None:
|
|
162
|
+
"""단계 경계에서 부르는 부수효과 — 관측 실패를 호출자에게 내지 않는다.
|
|
163
|
+
|
|
164
|
+
리드 세션은 resume·compaction 으로 세대가 갈리므로 prepare 때 적힌 단 하나의
|
|
165
|
+
id 는 런 구간에 레코드가 없는 죽은 세션을 가리킬 수 있다. 리드가 반드시
|
|
166
|
+
지나가는 지점마다 관측해 `leadSessionIds` 에 세대를 모은다 — 리드의 협조를
|
|
167
|
+
요구하지 않는 것이 요점이다.
|
|
168
|
+
|
|
169
|
+
잡는 범위가 OSError 뿐인 이유: 이 아래 호출들이 각자 자기 실패를 흡수하고,
|
|
170
|
+
그러고도 밖으로 나오는 것이 후보 정렬의 `p.stat()` 이다 — 스캔 도중 세션
|
|
171
|
+
jsonl 이 사라지면 여기서 OSError 가 난다. team-state 읽기·쓰기
|
|
172
|
+
(`load_json_object` / `mutate_team_state`)와 세션 본문 스캔이 그 흡수
|
|
173
|
+
지점이고, 비정상 team-state 가 낼 TypeError/AttributeError 는 잡는 대신 아예
|
|
174
|
+
내지 않는 쪽으로 막는다 — append 대상 키(`leadSessionIds` /
|
|
175
|
+
`observedTeamNames`)는 `add_observed_session` 이, 제외 집합의 출처
|
|
176
|
+
(`workerDispatches`)는 `worker_session_ids` 가 각각 비-list 를 걸러낸다.
|
|
177
|
+
더 넓게 잡으면 team-state 를 깨뜨리는 진짜 버그까지 조용히 삼킨다.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
record_observed_lead_session(project_root, team_state_path)
|
|
181
|
+
except OSError:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
|
|
128
185
|
def write_claude_resume_command_file(
|
|
129
186
|
*,
|
|
130
187
|
resume_command_path: Path,
|