okstra 0.178.0 → 0.179.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/README.md +2 -2
- package/dist/commands/execute/plan-verify.mjs +1 -1
- package/dist/commands/execute/worktree-status.mjs +8 -2
- package/dist/commands/execute/worktree-status.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +1 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/dist/commands/report/render-final-report.mjs +3 -3
- package/docs/architecture/storage-model.md +3 -3
- package/docs/architecture.md +10 -9
- package/docs/cli.md +11 -13
- package/docs/for-ai/skills/okstra-inspect.md +3 -3
- package/docs/for-ai/skills/okstra-schedule-gen.md +2 -2
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/project-structure-overview.md +10 -11
- package/docs/task-process/implementation-planning.md +1 -1
- package/docs/task-process/implementation.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +11 -12
- package/runtime/bin/lib/okstra/globals.sh +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +1 -1
- package/runtime/bin/lib/okstra/usage.sh +11 -9
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-central.sh +2 -2
- package/runtime/bin/okstra-render-final-report.py +1 -1
- package/runtime/bin/okstra-token-usage.py +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +6 -1
- package/runtime/prompts/lead/context-loader.md +3 -2
- package/runtime/prompts/lead/convergence.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
- package/runtime/prompts/lead/plan-body-verification.md +3 -3
- package/runtime/prompts/lead/report-writer.md +21 -20
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +5 -4
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +2 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +9 -5
- package/runtime/prompts/profiles/implementation.md +4 -4
- package/runtime/prompts/profiles/improvement-discovery.md +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +5 -4
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -4
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +71 -9
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -4
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +55 -3
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -3
- package/runtime/python/okstra_ctl/analysis_packet.py +21 -0
- package/runtime/python/okstra_ctl/backfill.py +12 -5
- package/runtime/python/okstra_ctl/consumers.py +70 -3
- package/runtime/python/okstra_ctl/convergence_engine.py +43 -17
- package/runtime/python/okstra_ctl/dispatch_core.py +47 -11
- package/runtime/python/okstra_ctl/dispatch_state.py +20 -19
- package/runtime/python/okstra_ctl/domain/worker_exec.py +13 -34
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +128 -0
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +5 -0
- package/runtime/python/okstra_ctl/final_report_paths.py +77 -1
- package/runtime/python/okstra_ctl/handoff.py +1 -2
- package/runtime/python/okstra_ctl/implementation_outcome.py +1 -1
- package/runtime/python/okstra_ctl/index.py +4 -4
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +26 -12
- package/runtime/python/okstra_ctl/listing.py +4 -2
- package/runtime/python/okstra_ctl/manager_launch.py +1 -1
- package/runtime/python/okstra_ctl/manager_sync.py +1 -1
- package/runtime/python/okstra_ctl/path_hints.py +2 -2
- package/runtime/python/okstra_ctl/paths.py +13 -10
- package/runtime/python/okstra_ctl/plan_run_root.py +9 -5
- package/runtime/python/okstra_ctl/recap.py +3 -2
- package/runtime/python/okstra_ctl/reconcile.py +3 -1
- package/runtime/python/okstra_ctl/render.py +22 -22
- package/runtime/python/okstra_ctl/report_finalize.py +4 -4
- package/runtime/python/okstra_ctl/rollup.py +1 -1
- package/runtime/python/okstra_ctl/run.py +139 -284
- package/runtime/python/okstra_ctl/run_audit.py +5 -5
- package/runtime/python/okstra_ctl/run_index_row.py +2 -2
- package/runtime/python/okstra_ctl/session_transcript.py +89 -0
- package/runtime/python/okstra_ctl/stage_ledger.py +72 -0
- package/runtime/python/okstra_ctl/stage_map.py +28 -29
- package/runtime/python/okstra_ctl/stage_targets.py +61 -0
- package/runtime/python/okstra_ctl/user_response.py +97 -12
- package/runtime/python/okstra_ctl/wizard.py +43 -65
- package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -7
- package/runtime/python/okstra_ctl/worker_runner.py +76 -213
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/python/okstra_ctl/wrapper_status.py +23 -0
- package/runtime/python/okstra_ctl/write_policy.py +45 -6
- package/runtime/python/okstra_project/state.py +2 -2
- package/runtime/python/okstra_token_usage/__init__.py +1 -1
- package/runtime/python/okstra_token_usage/cli.py +3 -3
- package/runtime/python/okstra_token_usage/report.py +7 -24
- package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
- package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
- package/runtime/schemas/final-report-v2.0.schema.json +11 -1
- package/runtime/skills/okstra-inspect/facets/history.md +3 -3
- package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
- package/runtime/skills/okstra-inspect/facets/report.md +5 -5
- package/runtime/skills/okstra-inspect/facets/status.md +2 -2
- package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-schedule-gen/SKILL.md +1 -1
- package/runtime/skills/okstra-user-response/SKILL.md +3 -3
- package/runtime/templates/project-docs/task-index.template.md +1 -1
- package/runtime/templates/report-writer-prompt-preamble.md +1 -1
- package/runtime/validators/forbidden_actions.py +76 -5
- package/runtime/validators/lib/fixtures.sh +14 -10
- package/runtime/validators/lib/runners.sh +1 -1
- package/runtime/validators/validate-implementation-plan-stages.py +3 -0
- package/runtime/validators/validate-report-views.py +1 -1
- package/runtime/validators/validate-run.py +95 -37
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"""Bundled Grok provider catalog."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
2
4
|
from typing import Any, Mapping
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
3
7
|
from okstra_ctl.domain.provider import (
|
|
4
8
|
LeadLaunchSpec,
|
|
5
9
|
ModelSpec,
|
|
@@ -8,12 +12,11 @@ from okstra_ctl.domain.provider import (
|
|
|
8
12
|
)
|
|
9
13
|
from okstra_ctl.domain.role import ALL_ROLE_TOKENS
|
|
10
14
|
from okstra_ctl.domain.worker_exec import (
|
|
11
|
-
STREAM_JSON,
|
|
12
15
|
ExecCommand,
|
|
13
16
|
PolicySupport,
|
|
14
17
|
WorkerExecRequest,
|
|
15
18
|
)
|
|
16
|
-
from okstra_ctl.domain.
|
|
19
|
+
from okstra_ctl.domain.worker_presentation import MergedText
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
GROK = {
|
|
@@ -23,23 +26,86 @@ GROK = {
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
|
|
29
|
+
def _catalog_model_id(observed: str) -> str:
|
|
30
|
+
"""Undo the `-build` suffix grok reports its served model under.
|
|
31
|
+
|
|
32
|
+
Measured on grok 1.x: asking for `grok-4.6` closes with
|
|
33
|
+
`modelUsage: {"grok-4.6-build": ...}`, and `grok-4.5` with
|
|
34
|
+
`grok-4.5-build`. The catalog holds the requested ids, so reading the
|
|
35
|
+
reported one as-is would fail the served-model gate on the very model
|
|
36
|
+
okstra asked for. A catalog entry that already carries the suffix
|
|
37
|
+
(`grok-build-0.1`) is matched first and left alone.
|
|
38
|
+
"""
|
|
39
|
+
if observed in GROK:
|
|
40
|
+
return observed
|
|
41
|
+
bare = observed.removesuffix("-build")
|
|
42
|
+
return bare if bare != observed and bare in GROK else observed
|
|
43
|
+
|
|
44
|
+
|
|
26
45
|
def normalise_served_model(raw_model: str | None) -> ServedModelAttestation:
|
|
27
46
|
if not raw_model or not raw_model.strip():
|
|
28
47
|
return ServedModelAttestation.unknown()
|
|
29
48
|
model_id = raw_model.strip().lower().removeprefix("grok/")
|
|
30
49
|
return ServedModelAttestation(
|
|
31
|
-
raw_model,
|
|
50
|
+
raw_model,
|
|
51
|
+
f"grok/{_catalog_model_id(model_id)}",
|
|
52
|
+
"exact",
|
|
53
|
+
"provider-output",
|
|
32
54
|
)
|
|
33
55
|
|
|
34
56
|
|
|
57
|
+
def served_model_from_session(
|
|
58
|
+
session_id: str, cwd: Path, *, home: Path | None = None
|
|
59
|
+
) -> str | None:
|
|
60
|
+
"""세션 기록에서 실제로 서빙된 모델을 읽는다.
|
|
61
|
+
|
|
62
|
+
grok 은 일하는 동안 어느 이벤트에도 모델을 적지 않고, 닫는 기록에만
|
|
63
|
+
남긴다. 스트림을 해석하지 않는 경로에서는 여기가 유일한 관측 지점이다.
|
|
64
|
+
디렉터리 이름은 cwd 를 퍼센트 인코딩한 것이다.
|
|
65
|
+
"""
|
|
66
|
+
root = (home or Path.home() / ".grok") / "sessions"
|
|
67
|
+
encoded = quote(str(cwd), safe="")
|
|
68
|
+
history = root / encoded / session_id / "chat_history.jsonl"
|
|
69
|
+
if not history.is_file():
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
for line in history.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
73
|
+
if not line.strip():
|
|
74
|
+
continue
|
|
75
|
+
record = json.loads(line)
|
|
76
|
+
model = record.get("model_id") if isinstance(record, Mapping) else None
|
|
77
|
+
if isinstance(model, str) and model.strip():
|
|
78
|
+
return model
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
return None
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
35
84
|
def observe_served_model(event: Mapping[str, Any]) -> str | None:
|
|
85
|
+
"""Read the model grok actually served from its closing `end` event.
|
|
86
|
+
|
|
87
|
+
grok names no model on the events it streams as it works; the only place
|
|
88
|
+
it states one is `end.modelUsage`, keyed by model. Looking for a top-level
|
|
89
|
+
`model` field — Claude's shape — found nothing on every event, so the run
|
|
90
|
+
recorded `servedModelAttestation: unknown` and the served-model gate had
|
|
91
|
+
nothing to check.
|
|
92
|
+
"""
|
|
36
93
|
model = event.get("model")
|
|
37
94
|
if isinstance(model, str) and model.strip():
|
|
38
95
|
return model
|
|
39
96
|
message = event.get("message")
|
|
40
97
|
if isinstance(message, Mapping):
|
|
41
98
|
model = message.get("model")
|
|
42
|
-
|
|
99
|
+
if isinstance(model, str) and model.strip():
|
|
100
|
+
return model
|
|
101
|
+
if event.get("type") == "end":
|
|
102
|
+
usage = event.get("modelUsage")
|
|
103
|
+
if isinstance(usage, Mapping):
|
|
104
|
+
names = [name for name in usage if isinstance(name, str) and name.strip()]
|
|
105
|
+
# More than one model in a single turn means the CLI switched
|
|
106
|
+
# mid-run; naming either one would be a guess, so report neither.
|
|
107
|
+
if len(names) == 1:
|
|
108
|
+
return names[0]
|
|
43
109
|
return None
|
|
44
110
|
|
|
45
111
|
GROK_LEAD_LAUNCH = LeadLaunchSpec(
|
|
@@ -66,16 +132,12 @@ class GrokExecution:
|
|
|
66
132
|
request.prompt_text,
|
|
67
133
|
"-m",
|
|
68
134
|
request.model,
|
|
69
|
-
"--output-format",
|
|
70
|
-
"streaming-json",
|
|
71
135
|
"--cwd",
|
|
72
136
|
str(cwd),
|
|
73
137
|
),
|
|
74
138
|
stdin_text=None,
|
|
75
|
-
stream_format=STREAM_JSON,
|
|
76
|
-
normalise=content_block_events,
|
|
77
|
-
observe_served_model=observe_served_model,
|
|
78
139
|
cwd=cwd,
|
|
140
|
+
presentation=MergedText(served_model_at_exit=served_model_from_session),
|
|
79
141
|
)
|
|
80
142
|
|
|
81
143
|
def policy_support(self) -> PolicySupport:
|
|
@@ -8,11 +8,11 @@ from okstra_ctl.domain.provider import (
|
|
|
8
8
|
)
|
|
9
9
|
from okstra_ctl.domain.role import ALL_ROLE_TOKENS
|
|
10
10
|
from okstra_ctl.domain.worker_exec import (
|
|
11
|
-
STREAM_JSON,
|
|
12
11
|
ExecCommand,
|
|
13
12
|
PolicySupport,
|
|
14
13
|
WorkerExecRequest,
|
|
15
14
|
)
|
|
15
|
+
from okstra_ctl.domain.worker_presentation import JsonEvents
|
|
16
16
|
from okstra_ctl.domain.worker_stream import content_block_events
|
|
17
17
|
|
|
18
18
|
|
|
@@ -72,10 +72,11 @@ class KimiExecution:
|
|
|
72
72
|
"stream-json",
|
|
73
73
|
),
|
|
74
74
|
stdin_text=None,
|
|
75
|
-
stream_format=STREAM_JSON,
|
|
76
|
-
normalise=content_block_events,
|
|
77
|
-
observe_served_model=observe_served_model,
|
|
78
75
|
cwd=cwd,
|
|
76
|
+
presentation=JsonEvents(
|
|
77
|
+
normalise=content_block_events,
|
|
78
|
+
observe=observe_served_model,
|
|
79
|
+
),
|
|
79
80
|
)
|
|
80
81
|
|
|
81
82
|
def policy_support(self) -> PolicySupport:
|
|
@@ -43,7 +43,11 @@ from .assignment_resolver import AssignmentContext, resolve_dispatch_assignment
|
|
|
43
43
|
from .path_hints import hydrate_active_run_context
|
|
44
44
|
from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
|
|
45
45
|
from .worker_artifact_paths import audit_sidecar_rel
|
|
46
|
-
from .wrapper_status import
|
|
46
|
+
from .wrapper_status import (
|
|
47
|
+
log_path_for_prompt,
|
|
48
|
+
prompt_derived_paths,
|
|
49
|
+
status_path_for_prompt,
|
|
50
|
+
)
|
|
47
51
|
from .worker_prompt_policy import resolve_prompt_plan_for_manifest
|
|
48
52
|
from .dispatch_state import (
|
|
49
53
|
BACKEND_CLI_WRAPPER,
|
|
@@ -489,8 +493,7 @@ def _dynamic_verifier_artifact_paths(
|
|
|
489
493
|
result_path,
|
|
490
494
|
audit_source_path,
|
|
491
495
|
project_root / audit_sidecar_rel(audit_rel),
|
|
492
|
-
|
|
493
|
-
log_path_for_prompt(prompt_path),
|
|
496
|
+
*prompt_derived_paths(prompt_path),
|
|
494
497
|
}
|
|
495
498
|
error_logs = active_context.get("errorLogs")
|
|
496
499
|
sidecars = error_logs.get("sidecarsByWorkerId") if isinstance(error_logs, Mapping) else None
|
|
@@ -628,12 +631,61 @@ def _validate_run_identity(
|
|
|
628
631
|
).duty_audience
|
|
629
632
|
except ValueError as exc:
|
|
630
633
|
raise AgentPromptCliError(str(exc)) from exc
|
|
634
|
+
if audience != expected and _manifest_issued_role_execution(
|
|
635
|
+
manifest, audience=audience, assignment_ref=assignment_ref
|
|
636
|
+
):
|
|
637
|
+
# The prompt plan names a worker's role by worker id, so on an
|
|
638
|
+
# implementation run the executor's provider resolves to the
|
|
639
|
+
# executor role no matter which role execution is being targeted.
|
|
640
|
+
# But the manifest issues one role execution per role, and a
|
|
641
|
+
# provider serving as both executor and verifier gets two — a
|
|
642
|
+
# normal roster, since the verifier contract accepts reusing the
|
|
643
|
+
# executor's model behind its own session. Refusing the second
|
|
644
|
+
# audience made a role execution okstra had itself issued
|
|
645
|
+
# undispatchable, which costs the run an independent verifier.
|
|
646
|
+
expected = audience
|
|
631
647
|
if audience != expected:
|
|
632
648
|
raise AgentPromptCliError(
|
|
633
649
|
f"audience {audience!r} does not match required audience {expected!r}"
|
|
634
650
|
)
|
|
635
651
|
|
|
636
652
|
|
|
653
|
+
def _manifest_issued_role_execution(
|
|
654
|
+
manifest: Mapping[str, Any],
|
|
655
|
+
*,
|
|
656
|
+
audience: str,
|
|
657
|
+
assignment_ref: str,
|
|
658
|
+
) -> bool:
|
|
659
|
+
"""Whether this run issued a role execution for that audience's role.
|
|
660
|
+
|
|
661
|
+
Keyed on the manifest's own rows, not on what the prompt plan infers from
|
|
662
|
+
a worker id: the question is whether okstra created the execution being
|
|
663
|
+
targeted, and only the manifest answers that.
|
|
664
|
+
"""
|
|
665
|
+
from .domain.role import RoleCatalogError, role_for_duty
|
|
666
|
+
|
|
667
|
+
try:
|
|
668
|
+
role = role_for_duty(audience)
|
|
669
|
+
except RoleCatalogError:
|
|
670
|
+
return False
|
|
671
|
+
assignments = manifest.get("invocationAssignments")
|
|
672
|
+
assignment = (
|
|
673
|
+
assignments.get(assignment_ref) if isinstance(assignments, Mapping) else None
|
|
674
|
+
)
|
|
675
|
+
provider = (
|
|
676
|
+
assignment.get("provider") if isinstance(assignment, Mapping) else None
|
|
677
|
+
)
|
|
678
|
+
if not provider:
|
|
679
|
+
return False
|
|
680
|
+
executions = manifest.get("roleExecutions")
|
|
681
|
+
return any(
|
|
682
|
+
isinstance(row, Mapping)
|
|
683
|
+
and row.get("role") == role
|
|
684
|
+
and row.get("provider") == provider
|
|
685
|
+
for row in (executions if isinstance(executions, list) else [])
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
637
689
|
def _materialize_standalone(
|
|
638
690
|
args: argparse.Namespace,
|
|
639
691
|
project_root: Path,
|
|
@@ -69,7 +69,9 @@ _EVIDENCE_RELATION_BY_TYPES = {
|
|
|
69
69
|
("change-impact-analysis", "project-analysis"): "project-context",
|
|
70
70
|
("change-impact-analysis", "feature-analysis"): "feature-baseline",
|
|
71
71
|
}
|
|
72
|
-
_REPORT_NAME_RE = re.compile(
|
|
72
|
+
_REPORT_NAME_RE = re.compile(
|
|
73
|
+
r"^final-report-.+-(?P<run_seq>[^-]+)\.data\.json$"
|
|
74
|
+
)
|
|
73
75
|
_FEATURE_ID_RE = re.compile(r"PF-\d{3}")
|
|
74
76
|
_FULL_COMMIT_RE = re.compile(r"[0-9a-f]{40}")
|
|
75
77
|
_RUN_SEQ_RE = re.compile(r"[0-9]{3}")
|
|
@@ -242,7 +244,7 @@ def load_analysis_report_candidate(
|
|
|
242
244
|
raise AnalysisInputError("data.json header.taskKey does not match task manifest")
|
|
243
245
|
if task_type != path_task_type:
|
|
244
246
|
raise AnalysisInputError("data.json header.taskType does not match report path")
|
|
245
|
-
if filename != f"final-report-{task_type}-{run_seq}.
|
|
247
|
+
if filename != f"final-report-{task_type}-{run_seq}.data.json":
|
|
246
248
|
raise AnalysisInputError("data.json analysisCommon.runSeq does not match report filename")
|
|
247
249
|
feature_index: Sequence[object] = ()
|
|
248
250
|
if task_type == "project-analysis":
|
|
@@ -289,7 +291,7 @@ def list_evidence_candidates(
|
|
|
289
291
|
return []
|
|
290
292
|
reports_root = root / ".okstra" / "tasks"
|
|
291
293
|
candidates: list[AnalysisReportCandidate] = []
|
|
292
|
-
for report in reports_root.glob("*/*/runs/*/reports/*.
|
|
294
|
+
for report in reports_root.glob("*/*/runs/*/reports/*.data.json"):
|
|
293
295
|
try:
|
|
294
296
|
candidate = load_analysis_report_candidate(root, report)
|
|
295
297
|
except AnalysisInputError:
|
|
@@ -100,6 +100,7 @@ def build_analysis_packet(
|
|
|
100
100
|
directive: str,
|
|
101
101
|
instruction_set_relative_path: str,
|
|
102
102
|
fix_history_text: str = "",
|
|
103
|
+
stage_ledger_json: str = "",
|
|
103
104
|
) -> str:
|
|
104
105
|
"""Return the primary compact input for Claude/Codex/Antigravity analysers."""
|
|
105
106
|
brief_text = task_brief_path.read_text(encoding="utf-8")
|
|
@@ -122,6 +123,7 @@ def build_analysis_packet(
|
|
|
122
123
|
parts.extend(_profile_block(task_type, profile_text))
|
|
123
124
|
parts.extend(_reference_block(reference_text))
|
|
124
125
|
parts.extend(_fix_history_block(fix_history_text))
|
|
126
|
+
parts.extend(_stage_ledger_block(stage_ledger_json))
|
|
125
127
|
parts.extend(_clarification_block(clarification_text))
|
|
126
128
|
parts.extend(_directive_block(directive))
|
|
127
129
|
return "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
|
|
@@ -229,6 +231,25 @@ def _fix_history_block(fix_history_text: str) -> list[str]:
|
|
|
229
231
|
]
|
|
230
232
|
|
|
231
233
|
|
|
234
|
+
def _stage_ledger_block(stage_ledger_json: str) -> list[str]:
|
|
235
|
+
if not stage_ledger_json.strip():
|
|
236
|
+
return []
|
|
237
|
+
return [
|
|
238
|
+
"",
|
|
239
|
+
"## Stage Ledger",
|
|
240
|
+
"",
|
|
241
|
+
"Facts about this task's stages as they stand on disk — not a plan.",
|
|
242
|
+
"A stage whose `status` is `done` is already implemented and will not",
|
|
243
|
+
"be executed again, so do not rewrite its plan body. Stage numbers",
|
|
244
|
+
"already listed here are taken; never reuse or renumber them.",
|
|
245
|
+
"",
|
|
246
|
+
"```json",
|
|
247
|
+
stage_ledger_json.strip(),
|
|
248
|
+
"```",
|
|
249
|
+
"",
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
|
|
232
253
|
def _clarification_block(clarification_text: str) -> list[str]:
|
|
233
254
|
if not clarification_text.strip():
|
|
234
255
|
return []
|
|
@@ -162,13 +162,20 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
|
|
|
162
162
|
continue
|
|
163
163
|
# 실제 okstra.sh 가 쓰는 manifest 키:
|
|
164
164
|
# - runDirectoryPath (디렉터리)
|
|
165
|
-
# -
|
|
165
|
+
# - expectedReportRecordPath (보고서, 호환: reportPath)
|
|
166
166
|
# - validation: { status, passed, ... } 중첩 dict
|
|
167
167
|
run_dir_rel = (manifest.get("runDirectoryPath")
|
|
168
168
|
or manifest.get("runDirPath", ""))
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
from .final_report_paths import report_record_rel_from_legacy_pointer
|
|
170
|
+
|
|
171
|
+
final_rel = report_record_rel_from_legacy_pointer(
|
|
172
|
+
manifest.get("expectedReportRecordPath")
|
|
173
|
+
or manifest.get("expectedReportPath")
|
|
174
|
+
or manifest.get("reportPath")
|
|
175
|
+
or manifest.get("finalReportRecordPath")
|
|
176
|
+
or manifest.get("finalReportPath")
|
|
177
|
+
or ""
|
|
178
|
+
)
|
|
172
179
|
# status 파일 경로는 RUN_STATUS_SEQ 기반이라 manifest seq
|
|
173
180
|
# 와 어긋날 수 있다. tail 이 정확한 파일을 추적하도록
|
|
174
181
|
# 매니페스트가 기록한 expectedStatusPath /
|
|
@@ -223,7 +230,7 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
|
|
|
223
230
|
finished_at=None if is_non_terminal else finished_at,
|
|
224
231
|
workers=raw_workers, lead_model=raw_lead_model,
|
|
225
232
|
validation=validation_status, run_dir_rel=run_dir_rel,
|
|
226
|
-
|
|
233
|
+
final_report_record_rel=final_rel,
|
|
227
234
|
final_status_rel=final_status_rel)
|
|
228
235
|
if is_non_terminal:
|
|
229
236
|
append_jsonl(home / "active.jsonl", row)
|
|
@@ -10,6 +10,8 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
13
15
|
from dataclasses import dataclass
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
from typing import Any, Dict, List, Optional
|
|
@@ -68,13 +70,25 @@ def read_consumers(plan_run_root: Path) -> List[Dict[str, Any]]:
|
|
|
68
70
|
|
|
69
71
|
|
|
70
72
|
def latest_done_by_stage(rows: List[Dict[str, Any]]) -> Dict[int, Dict[str, Any]]:
|
|
71
|
-
"""stage → 마지막 done row.
|
|
72
|
-
|
|
73
|
+
"""stage → 마지막 done row. 리드가 `failed` 로 철회한 stage 는 제외한다.
|
|
74
|
+
|
|
75
|
+
보정(reconciled) row 가 같은 stage 에 재-append 되므로 done 들 사이에서는
|
|
76
|
+
last-wins 다. done 행만 훑던 동안은 뒤에 붙은 `failed` 가 아무 효과가 없어
|
|
77
|
+
한 번 done 이 기록된 stage 를 되열 수단이 없었다 — `--stage N` 은 계속
|
|
78
|
+
거부되고, 되돌리기가 필요해진 stage 를 okstra 밖에서 처리한 뒤 원장을 손으로
|
|
79
|
+
맞춰야 했다. 그 보정을 놓치면 다음 stage 가 되돌리기 이전 트리에서 갈라진다.
|
|
80
|
+
|
|
81
|
+
`started` 는 여기서 stage 를 내리지 않는다. fix run 이 done 인 stage 에
|
|
82
|
+
`started` 를 다시 기록하는 것은 정상 흐름이고(`_equivalent_row_exists`),
|
|
83
|
+
그동안 의존 stage 는 종전대로 마지막 done head 에서 base 를 잡는다.
|
|
84
|
+
철회는 리드의 명시적 판정인 `failed` 하나로만 일어난다.
|
|
85
|
+
"""
|
|
86
|
+
last = last_lifecycle_status_by_stage(rows)
|
|
73
87
|
out: Dict[int, Dict[str, Any]] = {}
|
|
74
88
|
for r in rows:
|
|
75
89
|
if r.get("status") == "done" and isinstance(r.get("stage"), int):
|
|
76
90
|
out[r["stage"]] = r
|
|
77
|
-
return out
|
|
91
|
+
return {stage: row for stage, row in out.items() if last.get(stage) != "failed"}
|
|
78
92
|
|
|
79
93
|
|
|
80
94
|
def read_stage_consumer_state(
|
|
@@ -132,6 +146,8 @@ def append_consumer(plan_run_root: Path, *, impl_task_key: str, stage: int,
|
|
|
132
146
|
**fields,
|
|
133
147
|
}
|
|
134
148
|
_append_row(plan_run_root, record)
|
|
149
|
+
if status == "done":
|
|
150
|
+
_tag_stage_exit(plan_run_root, stage, fields.get("head_commit"))
|
|
135
151
|
# 종결 status 는 점유 해제 이벤트이기도 하다 — 중복 append(no-op)에서도 풀어야
|
|
136
152
|
# release 없이 done 만 기록된 과거 run 의 잔존 점유가 다음 호출에서 치유된다.
|
|
137
153
|
if status == "done":
|
|
@@ -140,6 +156,57 @@ def append_consumer(plan_run_root: Path, *, impl_task_key: str, stage: int,
|
|
|
140
156
|
_release_stage_occupancy_keeping_branch(impl_task_key, stage)
|
|
141
157
|
|
|
142
158
|
|
|
159
|
+
STAGE_EXIT_TAG = "stage-{stage}-exit"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _repo_root(start: Path) -> Optional[Path]:
|
|
163
|
+
for candidate in [start, *start.parents]:
|
|
164
|
+
if (candidate / ".git").exists():
|
|
165
|
+
return candidate
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _tag_stage_exit(plan_run_root: Path, stage: int, head_commit: Any) -> None:
|
|
170
|
+
"""Move `stage-<N>-exit` to the commit this ledger row records.
|
|
171
|
+
|
|
172
|
+
The tag used to be a plan step, so it was written while the stage was still
|
|
173
|
+
running — before the carry evidence the ledger reads. A stage then had three
|
|
174
|
+
exit points that could disagree: the tag, the recorded `head_commit`, and
|
|
175
|
+
the branch tip. okstra writes the tag at the moment it settles the stage, so
|
|
176
|
+
the tag and the ledger cannot drift apart.
|
|
177
|
+
|
|
178
|
+
Best effort by design: the ledger is the record and a tag is a convenience
|
|
179
|
+
for the reader. A tree that is not a git repository, or a commit git cannot
|
|
180
|
+
resolve, leaves the row written and says so on stderr.
|
|
181
|
+
"""
|
|
182
|
+
if not isinstance(head_commit, str) or not head_commit.strip():
|
|
183
|
+
return
|
|
184
|
+
root = _repo_root(Path(plan_run_root).resolve())
|
|
185
|
+
if root is None:
|
|
186
|
+
return
|
|
187
|
+
commit = head_commit.strip()
|
|
188
|
+
tag = STAGE_EXIT_TAG.format(stage=stage)
|
|
189
|
+
try:
|
|
190
|
+
exists = subprocess.run(
|
|
191
|
+
["git", "-C", str(root), "cat-file", "-e", f"{commit}^{{commit}}"],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
)
|
|
194
|
+
if exists.returncode != 0:
|
|
195
|
+
print(
|
|
196
|
+
f"okstra: stage {stage} done recorded, but {commit[:12]} is not "
|
|
197
|
+
f"a commit in {root} — `{tag}` not moved",
|
|
198
|
+
file=sys.stderr,
|
|
199
|
+
)
|
|
200
|
+
return
|
|
201
|
+
subprocess.run(
|
|
202
|
+
["git", "-C", str(root), "tag", "-f", tag, commit],
|
|
203
|
+
capture_output=True,
|
|
204
|
+
check=True,
|
|
205
|
+
)
|
|
206
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
207
|
+
print(f"okstra: could not move `{tag}` to {commit[:12]}: {exc}", file=sys.stderr)
|
|
208
|
+
|
|
209
|
+
|
|
143
210
|
def _equivalent_row_exists(plan_run_root: Path, impl_task_key: str, stage: int,
|
|
144
211
|
status: str, force_reappend: bool,
|
|
145
212
|
head_commit: Any) -> bool:
|
|
@@ -13,7 +13,12 @@ WORKING_SCHEMA_VERSION = "1.0"
|
|
|
13
13
|
V2_WORKING_SCHEMA_VERSION = "2.0"
|
|
14
14
|
EXECUTION_IDENTITY_VERSION = 2
|
|
15
15
|
FINAL_SCHEMA_VERSION = "1.3"
|
|
16
|
-
_AUDIENCES = {"analysis", "report-writer"}
|
|
16
|
+
_AUDIENCES = {"analysis", "lead", "report-writer"}
|
|
17
|
+
# 발견을 낼 수 있는 audience. 리드는 계약상 Phase 6 자체 리뷰를 수행하므로
|
|
18
|
+
# 출처가 될 수 있지만, 교차검증 표는 아니다 — 합의 수는 아래에서 분석 워커만
|
|
19
|
+
# 센다. 리드를 분석 워커로 위장 선언하는 것을 계약이 금지하는 이상, 리드의
|
|
20
|
+
# 발견을 담을 자리가 없으면 그 리뷰 결과는 어디에도 못 간다.
|
|
21
|
+
_FINDING_SOURCE_AUDIENCES = {"analysis", "lead"}
|
|
17
22
|
_AUDIENCE_SOURCE_ROLES = {
|
|
18
23
|
"analysis": frozenset({"analyser", "designer", "planner", "verifier"}),
|
|
19
24
|
"report-writer": frozenset({"report-writer"}),
|
|
@@ -79,6 +84,11 @@ def seed_working_state(
|
|
|
79
84
|
analysis_workers = [
|
|
80
85
|
worker["workerId"] for worker in workers if worker["audience"] == "analysis"
|
|
81
86
|
]
|
|
87
|
+
finding_sources = [
|
|
88
|
+
worker["workerId"]
|
|
89
|
+
for worker in workers
|
|
90
|
+
if worker["audience"] in _FINDING_SOURCE_AUDIENCES
|
|
91
|
+
]
|
|
82
92
|
state = {
|
|
83
93
|
"schemaVersion": source["schemaVersion"],
|
|
84
94
|
"taskKey": task_key,
|
|
@@ -104,7 +114,10 @@ def seed_working_state(
|
|
|
104
114
|
return state
|
|
105
115
|
|
|
106
116
|
findings, queue = _parse_groups(
|
|
107
|
-
source.get("groups"),
|
|
117
|
+
source.get("groups"),
|
|
118
|
+
analysis_workers,
|
|
119
|
+
finding_sources,
|
|
120
|
+
adversarial=config["adversarial"],
|
|
108
121
|
)
|
|
109
122
|
state["findings"] = findings
|
|
110
123
|
state["queueFindingIds"] = queue
|
|
@@ -602,7 +615,8 @@ def validate_working_state(state: Mapping[str, Any]) -> list[str]:
|
|
|
602
615
|
errors.append(
|
|
603
616
|
f"workers[{index}].audience is unsupported: {audience!r}. "
|
|
604
617
|
"Finding-producing workers (implementation verifiers included) "
|
|
605
|
-
"use 'analysis';
|
|
618
|
+
"use 'analysis'; the okstra lead's own review uses 'lead'; "
|
|
619
|
+
"only the report author uses 'report-writer'. "
|
|
606
620
|
f"Allowed: {sorted(_AUDIENCES)}."
|
|
607
621
|
)
|
|
608
622
|
participant_ref = worker.get("participantRef")
|
|
@@ -2042,8 +2056,9 @@ def _parse_workers(value: Any, identity_version: int) -> list[dict[str, str]]:
|
|
|
2042
2056
|
f"workers[{index}] has unsupported audience: {audience}. "
|
|
2043
2057
|
"Convergence audience is a functional role, not a phase label: "
|
|
2044
2058
|
"every finding-producing worker uses 'analysis' (an "
|
|
2045
|
-
"implementation run's verifiers included),
|
|
2046
|
-
|
|
2059
|
+
"implementation run's verifiers included), the okstra lead's own "
|
|
2060
|
+
"review uses 'lead', and only the report author uses "
|
|
2061
|
+
f"'report-writer'. Allowed: {sorted(_AUDIENCES)}."
|
|
2047
2062
|
)
|
|
2048
2063
|
if worker_id in seen:
|
|
2049
2064
|
raise ConvergenceContractError(f"duplicate workerId: {worker_id}")
|
|
@@ -2125,6 +2140,7 @@ def _validate_worker_execution_identity(
|
|
|
2125
2140
|
def _parse_groups(
|
|
2126
2141
|
value: Any,
|
|
2127
2142
|
analysis_workers: list[str],
|
|
2143
|
+
finding_sources: list[str],
|
|
2128
2144
|
*,
|
|
2129
2145
|
adversarial: bool,
|
|
2130
2146
|
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
@@ -2139,7 +2155,9 @@ def _parse_groups(
|
|
|
2139
2155
|
if finding_id in seen_ids:
|
|
2140
2156
|
raise ConvergenceContractError(f"duplicate findingId: {finding_id}")
|
|
2141
2157
|
seen_ids.add(finding_id)
|
|
2142
|
-
finding, source_workers = _parse_group(
|
|
2158
|
+
finding, source_workers = _parse_group(
|
|
2159
|
+
group, index, analysis_workers, finding_sources
|
|
2160
|
+
)
|
|
2143
2161
|
if len(source_workers) >= 2 and not adversarial:
|
|
2144
2162
|
finding["classification"] = "full-consensus"
|
|
2145
2163
|
else:
|
|
@@ -2152,23 +2170,30 @@ def _parse_group(
|
|
|
2152
2170
|
group: Mapping[str, Any],
|
|
2153
2171
|
index: int,
|
|
2154
2172
|
analysis_workers: list[str],
|
|
2173
|
+
finding_sources: list[str],
|
|
2155
2174
|
) -> tuple[dict[str, Any], list[str]]:
|
|
2175
|
+
"""그룹 하나를 읽는다. 출처는 발견을 낼 수 있는 모두, 합의는 분석 워커만.
|
|
2176
|
+
|
|
2177
|
+
두 목록을 따로 받는 이유가 이 함수의 전부다. 리드도 발견을 낼 수 있어야
|
|
2178
|
+
하지만 그 발견이 교차검증 표로 세어지면, 독립 검증자 없이 해소된 것처럼
|
|
2179
|
+
보이는 findings 가 생긴다.
|
|
2180
|
+
"""
|
|
2156
2181
|
label = f"groups[{index}]"
|
|
2157
2182
|
origin = _required_string(group, "originWorker", label)
|
|
2158
|
-
if origin not in
|
|
2183
|
+
if origin not in finding_sources:
|
|
2159
2184
|
raise ConvergenceContractError(
|
|
2160
|
-
f"{label}.originWorker
|
|
2185
|
+
f"{label}.originWorker cannot produce findings in this run: {origin}"
|
|
2161
2186
|
)
|
|
2162
2187
|
origin_evidence = _required_string(group, "originEvidence", label)
|
|
2163
2188
|
discovered_by = _parse_discovered_by(
|
|
2164
|
-
group.get("discoveredBy"), label,
|
|
2189
|
+
group.get("discoveredBy"), label, finding_sources
|
|
2165
2190
|
)
|
|
2166
2191
|
if origin not in discovered_by:
|
|
2167
2192
|
raise ConvergenceContractError(
|
|
2168
2193
|
f"{label}.originWorker must appear in discoveredBy"
|
|
2169
2194
|
)
|
|
2170
2195
|
source_items = _parse_source_items(
|
|
2171
|
-
group.get("sourceItems"), label,
|
|
2196
|
+
group.get("sourceItems"), label, finding_sources
|
|
2172
2197
|
)
|
|
2173
2198
|
source_workers = [
|
|
2174
2199
|
worker_id
|
|
@@ -2243,14 +2268,15 @@ def _is_normalized_okstra_path(path: str) -> bool:
|
|
|
2243
2268
|
def _parse_discovered_by(
|
|
2244
2269
|
value: Any,
|
|
2245
2270
|
label: str,
|
|
2246
|
-
|
|
2271
|
+
finding_sources: list[str],
|
|
2247
2272
|
) -> dict[str, dict[str, str]]:
|
|
2248
2273
|
raw = _object(value, f"{label}.discoveredBy")
|
|
2249
2274
|
parsed: dict[str, dict[str, str]] = {}
|
|
2250
2275
|
for worker_id, details_value in raw.items():
|
|
2251
|
-
if worker_id not in
|
|
2276
|
+
if worker_id not in finding_sources:
|
|
2252
2277
|
raise ConvergenceContractError(
|
|
2253
|
-
f"{label}.discoveredBy contains
|
|
2278
|
+
f"{label}.discoveredBy contains a worker that produces no "
|
|
2279
|
+
f"findings in this run: {worker_id}"
|
|
2254
2280
|
)
|
|
2255
2281
|
details = _object(details_value, f"{label}.discoveredBy.{worker_id}")
|
|
2256
2282
|
parsed[worker_id] = {
|
|
@@ -2269,7 +2295,7 @@ def _parse_discovered_by(
|
|
|
2269
2295
|
def _parse_source_items(
|
|
2270
2296
|
value: Any,
|
|
2271
2297
|
label: str,
|
|
2272
|
-
|
|
2298
|
+
finding_sources: list[str],
|
|
2273
2299
|
) -> list[dict[str, str]]:
|
|
2274
2300
|
if not isinstance(value, list) or not value:
|
|
2275
2301
|
raise ConvergenceContractError(f"{label}.sourceItems must be a non-empty array")
|
|
@@ -2277,10 +2303,10 @@ def _parse_source_items(
|
|
|
2277
2303
|
for index, raw in enumerate(value):
|
|
2278
2304
|
item = _object(raw, f"{label}.sourceItems[{index}]")
|
|
2279
2305
|
worker = _required_string(item, "worker", f"{label}.sourceItems[{index}]")
|
|
2280
|
-
if worker not in
|
|
2306
|
+
if worker not in finding_sources:
|
|
2281
2307
|
raise ConvergenceContractError(
|
|
2282
|
-
f"{label}.sourceItems contains
|
|
2283
|
-
"report-writer never votes"
|
|
2308
|
+
f"{label}.sourceItems contains {worker}, which produces no "
|
|
2309
|
+
"findings in this run; report-writer never votes"
|
|
2284
2310
|
)
|
|
2285
2311
|
items.append(
|
|
2286
2312
|
{
|