okstra 0.136.0 → 0.138.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 +1 -1
- package/docs/architecture.md +3 -2
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +32 -5
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-spawn-followups.py +4 -9
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/adapters/codex.md +1 -0
- package/runtime/prompts/lead/adapters/external.md +1 -0
- package/runtime/prompts/lead/plan-body-verification.md +2 -2
- package/runtime/prompts/lead/report-writer.md +11 -10
- package/runtime/prompts/lead/team-contract.md +4 -2
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
- package/runtime/python/okstra_ctl/consumers.py +7 -5
- package/runtime/python/okstra_ctl/context_cost.py +4 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
- package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
- package/runtime/python/okstra_ctl/error_report.py +2 -7
- package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
- package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
- package/runtime/python/okstra_ctl/path_hints.py +3 -3
- package/runtime/python/okstra_ctl/paths.py +17 -2
- package/runtime/python/okstra_ctl/recap.py +5 -12
- package/runtime/python/okstra_ctl/render.py +18 -19
- package/runtime/python/okstra_ctl/report_finalize.py +8 -4
- package/runtime/python/okstra_ctl/report_language.py +83 -0
- package/runtime/python/okstra_ctl/run.py +262 -328
- package/runtime/python/okstra_ctl/set_work_status.py +2 -1
- package/runtime/python/okstra_ctl/stage_targets.py +330 -101
- package/runtime/python/okstra_ctl/time_report.py +4 -9
- package/runtime/python/okstra_ctl/wizard.py +47 -49
- package/runtime/python/okstra_ctl/work_categories.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
- package/runtime/python/okstra_ctl/worktree.py +37 -29
- package/runtime/python/okstra_project/__init__.py +4 -0
- package/runtime/python/okstra_project/dirs.py +7 -0
- package/runtime/python/okstra_project/state.py +78 -8
- package/runtime/validators/lib/fixtures.sh +2 -0
- package/runtime/validators/validate-run.py +3 -21
- package/src/commands/inspect/stage-map.mjs +12 -19
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Worker dispatch plumbing shared by both lead dispatchers.
|
|
2
|
+
|
|
3
|
+
`dispatch_core` (claude / external lead) and `codex_dispatch` (codex lead) differ
|
|
4
|
+
in capability — only the former has tmux panes, blocking waits, and retry from a
|
|
5
|
+
persisted record. What they do *not* differ in is the job value object and the
|
|
6
|
+
run's state files: the same run-manifest keys, the same team-state document, the
|
|
7
|
+
same wrapper argv.
|
|
8
|
+
|
|
9
|
+
Those used to be two copies that had already drifted apart: one dispatcher
|
|
10
|
+
wrapped a truncated team-state in `DispatchError` while the other let
|
|
11
|
+
`json.JSONDecodeError` escape, one raised on an unknown `workerId` while the
|
|
12
|
+
other silently did nothing, and one gated the worktree argument on task type
|
|
13
|
+
while the other handed it to every phase. This module is the single reference
|
|
14
|
+
point so a fix cannot land on one lead runtime only.
|
|
15
|
+
|
|
16
|
+
Enforced by `tests/contract/test_dispatcher_shared_helpers.py`.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Mapping, Sequence
|
|
26
|
+
|
|
27
|
+
from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
|
|
28
|
+
|
|
29
|
+
BACKEND_CLI_WRAPPER = "cli-wrapper"
|
|
30
|
+
BACKEND_TMUX_PANE = "tmux-pane"
|
|
31
|
+
BACKEND_MIXED = "mixed"
|
|
32
|
+
|
|
33
|
+
# The worktree argument grants the wrapper `--add-dir` write access outside
|
|
34
|
+
# project-root. Only the phases whose workers mutate a stage worktree get it;
|
|
35
|
+
# analysis phases write their artifacts under project-root (see the contract in
|
|
36
|
+
# scripts/okstra-codex-exec.sh).
|
|
37
|
+
WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DispatchError(Exception):
|
|
41
|
+
"""Raised when a worker dispatch request cannot be executed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class WorkerJob:
|
|
46
|
+
worker_id: str
|
|
47
|
+
provider: str
|
|
48
|
+
backend: str
|
|
49
|
+
project_root: Path
|
|
50
|
+
model_execution_value: str
|
|
51
|
+
wrapper_path: Path
|
|
52
|
+
prompt_path: Path
|
|
53
|
+
result_path: Path
|
|
54
|
+
worker_result_path: Path
|
|
55
|
+
completion_paths: tuple[Path, ...]
|
|
56
|
+
worktree_path: str
|
|
57
|
+
role: str
|
|
58
|
+
idle_timeout_seconds: int
|
|
59
|
+
dispatch_kind: str
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def command(self) -> list[str]:
|
|
63
|
+
return [
|
|
64
|
+
str(self.wrapper_path),
|
|
65
|
+
str(self.project_root),
|
|
66
|
+
self.model_execution_value,
|
|
67
|
+
str(self.prompt_path),
|
|
68
|
+
self.worktree_path,
|
|
69
|
+
self.role,
|
|
70
|
+
str(self.idle_timeout_seconds),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
def to_payload(self) -> dict[str, Any]:
|
|
74
|
+
return {
|
|
75
|
+
"workerId": self.worker_id,
|
|
76
|
+
"provider": self.provider,
|
|
77
|
+
"backend": self.backend,
|
|
78
|
+
"modelExecutionValue": self.model_execution_value,
|
|
79
|
+
"wrapperPath": str(self.wrapper_path),
|
|
80
|
+
"promptPath": str(self.prompt_path),
|
|
81
|
+
"resultPath": str(self.result_path),
|
|
82
|
+
"workerResultPath": str(self.worker_result_path),
|
|
83
|
+
"completionPaths": [str(path) for path in self.completion_paths],
|
|
84
|
+
"worktreePath": self.worktree_path,
|
|
85
|
+
"role": self.role,
|
|
86
|
+
"dispatchKind": self.dispatch_kind,
|
|
87
|
+
"command": self.command,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- run-manifest reads -------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def string_value(value: Any) -> str:
|
|
94
|
+
return value.strip() if isinstance(value, str) else ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def require_string(payload: Mapping[str, Any], key: str) -> str:
|
|
98
|
+
value = payload.get(key)
|
|
99
|
+
if not isinstance(value, str) or not value.strip():
|
|
100
|
+
raise DispatchError(f"missing required string field: {key}")
|
|
101
|
+
return value.strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def string_list(value: Any) -> list[str]:
|
|
105
|
+
if not isinstance(value, list):
|
|
106
|
+
return []
|
|
107
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_project_path(project_root: Path, value: str) -> Path:
|
|
111
|
+
path = Path(value)
|
|
112
|
+
return path if path.is_absolute() else project_root / path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def resolve_required_path(
|
|
116
|
+
project_root: Path, manifest: Mapping[str, Any], key: str
|
|
117
|
+
) -> Path:
|
|
118
|
+
return resolve_project_path(project_root, require_string(manifest, key))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def worker_prompt_path(manifest: Mapping[str, Any], worker_id: str) -> str:
|
|
122
|
+
paths = manifest.get("workerPromptPathByWorkerId")
|
|
123
|
+
if not isinstance(paths, Mapping):
|
|
124
|
+
raise DispatchError("run manifest has no workerPromptPathByWorkerId object")
|
|
125
|
+
return require_string(paths, worker_id)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def worktree_path(
|
|
129
|
+
manifest: Mapping[str, Any], active_context: Mapping[str, Any]
|
|
130
|
+
) -> str:
|
|
131
|
+
if manifest.get("taskType") not in WORKTREE_TASK_TYPES:
|
|
132
|
+
return ""
|
|
133
|
+
worktree = active_context.get("executorWorktree")
|
|
134
|
+
if not isinstance(worktree, Mapping):
|
|
135
|
+
return ""
|
|
136
|
+
return str(worktree.get("path") or "")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- team-state document ------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def load_json_object(path: Path, label: str) -> dict[str, Any]:
|
|
142
|
+
if not path.is_file():
|
|
143
|
+
raise DispatchError(f"{label} not found: {path}")
|
|
144
|
+
try:
|
|
145
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
146
|
+
except json.JSONDecodeError as exc:
|
|
147
|
+
raise DispatchError(f"{label} is invalid JSON: {path}: {exc}") from exc
|
|
148
|
+
if not isinstance(payload, dict):
|
|
149
|
+
raise DispatchError(f"{label} must be a JSON object: {path}")
|
|
150
|
+
return payload
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
|
154
|
+
# Write via temp file + os.replace so a crash mid-write cannot leave a
|
|
155
|
+
# truncated team-state.json that every later load_json_object rejects;
|
|
156
|
+
# os.replace is atomic on POSIX, matching run_context._atomic_write_json.
|
|
157
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
158
|
+
tmp.write_text(
|
|
159
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
160
|
+
)
|
|
161
|
+
os.replace(tmp, path)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
|
|
165
|
+
workers = team_state.get("workers")
|
|
166
|
+
if not isinstance(workers, list):
|
|
167
|
+
raise DispatchError("team-state workers must be an array")
|
|
168
|
+
for worker in workers:
|
|
169
|
+
if isinstance(worker, Mapping) and worker.get("workerId") == worker_id:
|
|
170
|
+
return worker
|
|
171
|
+
raise DispatchError(f"team-state has no workerId={worker_id}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def set_worker_status(
|
|
175
|
+
team_state_path: Path,
|
|
176
|
+
worker_id: str,
|
|
177
|
+
status: str,
|
|
178
|
+
reason: str,
|
|
179
|
+
*,
|
|
180
|
+
model_execution_value: str = "",
|
|
181
|
+
) -> None:
|
|
182
|
+
payload = load_json_object(team_state_path, "team-state")
|
|
183
|
+
workers = payload.get("workers")
|
|
184
|
+
if not isinstance(workers, list):
|
|
185
|
+
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
186
|
+
for worker in workers:
|
|
187
|
+
if isinstance(worker, dict) and worker.get("workerId") == worker_id:
|
|
188
|
+
worker["status"] = status
|
|
189
|
+
worker["reason"] = reason
|
|
190
|
+
if model_execution_value:
|
|
191
|
+
worker["model"] = model_execution_value
|
|
192
|
+
worker["modelExecutionValue"] = model_execution_value
|
|
193
|
+
write_json(team_state_path, payload)
|
|
194
|
+
return
|
|
195
|
+
raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
|
|
199
|
+
payload = load_json_object(team_state_path, "team-state")
|
|
200
|
+
payload["dispatchMode"] = dispatch_mode
|
|
201
|
+
write_json(team_state_path, payload)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- job facts ----------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
|
|
207
|
+
backends = {job.backend for job in jobs}
|
|
208
|
+
if len(backends) == 1:
|
|
209
|
+
return next(iter(backends))
|
|
210
|
+
return BACKEND_MIXED
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
|
|
214
|
+
return tuple(path for path in job.completion_paths if not path.is_file())
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def validate_initial_prompts(
|
|
218
|
+
manifest: Mapping[str, Any], jobs: Sequence[WorkerJob]
|
|
219
|
+
) -> None:
|
|
220
|
+
records = [
|
|
221
|
+
PromptRecord(job.worker_id, job.dispatch_kind, job.prompt_path)
|
|
222
|
+
for job in jobs
|
|
223
|
+
]
|
|
224
|
+
errors = validate_initial_prompt_records(manifest=manifest, records=records)
|
|
225
|
+
if errors:
|
|
226
|
+
task_type = require_string(manifest, "taskType")
|
|
227
|
+
raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def utc_now() -> str:
|
|
231
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -13,6 +13,7 @@ import sys
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
15
|
from okstra_ctl.task_target import resolve_task_root, project_rel
|
|
16
|
+
from okstra_project import read_task_key
|
|
16
17
|
from okstra_ctl.error_log_core import (
|
|
17
18
|
glob_error_logs,
|
|
18
19
|
parse_records,
|
|
@@ -80,13 +81,7 @@ def _timestamp_segment(now: dt.datetime) -> str:
|
|
|
80
81
|
|
|
81
82
|
|
|
82
83
|
def build_and_write(task_root: Path, project_root: Path, now: dt.datetime) -> dict:
|
|
83
|
-
|
|
84
|
-
task_key = ""
|
|
85
|
-
if manifest_path.is_file():
|
|
86
|
-
try:
|
|
87
|
-
task_key = json.loads(manifest_path.read_text(encoding="utf-8")).get("taskKey", "")
|
|
88
|
-
except Exception:
|
|
89
|
-
task_key = ""
|
|
84
|
+
task_key = read_task_key(task_root)
|
|
90
85
|
paths = glob_error_logs(task_root)
|
|
91
86
|
records, skipped = parse_records(paths)
|
|
92
87
|
agg = aggregate(records)
|
|
@@ -6,8 +6,12 @@ from dataclasses import dataclass, field
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
from .consumers import
|
|
10
|
-
|
|
9
|
+
from .consumers import (
|
|
10
|
+
FAILED_CARRY_STATUSES,
|
|
11
|
+
carry_head_commit,
|
|
12
|
+
read_stage_consumer_state,
|
|
13
|
+
)
|
|
14
|
+
from .paths import RunRef, task_manifest_file
|
|
11
15
|
from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
|
|
12
16
|
|
|
13
17
|
|
|
@@ -23,7 +27,7 @@ class ImplementationOutcome:
|
|
|
23
27
|
|
|
24
28
|
def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
25
29
|
task_root = Path(task_root)
|
|
26
|
-
manifest = _load_json(task_root
|
|
30
|
+
manifest = _load_json(task_manifest_file(task_root))
|
|
27
31
|
workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
|
|
28
32
|
if workflow.get("currentPhase") != "implementation":
|
|
29
33
|
return ImplementationOutcome(completed=False, reason="current phase is not implementation")
|
|
@@ -32,7 +36,7 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
|
32
36
|
if not plan_run_root.is_dir():
|
|
33
37
|
return ImplementationOutcome(completed=False, reason="implementation-planning run root missing")
|
|
34
38
|
|
|
35
|
-
stage_map =
|
|
39
|
+
stage_map = load_stage_map(task_root, manifest)
|
|
36
40
|
if not stage_map:
|
|
37
41
|
return ImplementationOutcome(completed=False, reason="stage map unavailable")
|
|
38
42
|
|
|
@@ -53,7 +57,7 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
|
53
57
|
return ImplementationOutcome(
|
|
54
58
|
completed=True,
|
|
55
59
|
stages=sorted(required_stages),
|
|
56
|
-
head_commit=str(latest_done.get("head_commit") or "") or
|
|
60
|
+
head_commit=str(latest_done.get("head_commit") or "") or carry_head_commit(latest_carry),
|
|
57
61
|
report_path=_latest_implementation_report(task_root),
|
|
58
62
|
next_recommended_phase=DEFAULT_NEXT_PHASE["implementation"],
|
|
59
63
|
reason="all implementation stages have pass-grade carry and done rows",
|
|
@@ -67,7 +71,7 @@ def reconcile_implementation_outcome(project_root: Path, task_root: Path) -> boo
|
|
|
67
71
|
if not outcome.completed:
|
|
68
72
|
return False
|
|
69
73
|
|
|
70
|
-
manifest_path = task_root
|
|
74
|
+
manifest_path = task_manifest_file(task_root)
|
|
71
75
|
manifest = _load_json(manifest_path)
|
|
72
76
|
workflow = manifest.get("workflow")
|
|
73
77
|
if not isinstance(workflow, dict):
|
|
@@ -110,7 +114,7 @@ def _load_json(path: Path) -> dict[str, Any]:
|
|
|
110
114
|
return data if isinstance(data, dict) else {}
|
|
111
115
|
|
|
112
116
|
|
|
113
|
-
def
|
|
117
|
+
def load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
114
118
|
plan_path = _source_plan_path(task_root, manifest)
|
|
115
119
|
if not plan_path.is_file():
|
|
116
120
|
return []
|
|
@@ -177,7 +181,7 @@ def _load_carry(task_root: Path, stage: int) -> dict[str, Any] | None:
|
|
|
177
181
|
|
|
178
182
|
def _carry_passed(carry: dict[str, Any]) -> bool:
|
|
179
183
|
status = str(carry.get("status") or "").lower()
|
|
180
|
-
if status in
|
|
184
|
+
if status in FAILED_CARRY_STATUSES:
|
|
181
185
|
return False
|
|
182
186
|
conformance = carry.get("conformance")
|
|
183
187
|
if isinstance(conformance, dict):
|
|
@@ -188,21 +192,6 @@ def _carry_passed(carry: dict[str, Any]) -> bool:
|
|
|
188
192
|
return not (isinstance(unverified, list) and unverified)
|
|
189
193
|
|
|
190
194
|
|
|
191
|
-
def _carry_head_commit(carry: dict[str, Any]) -> str:
|
|
192
|
-
stage_range = carry.get("stageCommitRange")
|
|
193
|
-
if isinstance(stage_range, dict) and stage_range.get("head"):
|
|
194
|
-
return str(stage_range["head"])
|
|
195
|
-
for key in ("head_sha", "head_commit", "head"):
|
|
196
|
-
value = carry.get(key)
|
|
197
|
-
if value:
|
|
198
|
-
return str(value)
|
|
199
|
-
commits = carry.get("commits")
|
|
200
|
-
if isinstance(commits, list) and commits:
|
|
201
|
-
last = commits[-1]
|
|
202
|
-
if isinstance(last, dict) and last.get("sha"):
|
|
203
|
-
return str(last["sha"])
|
|
204
|
-
return ""
|
|
205
|
-
|
|
206
195
|
|
|
207
196
|
def _latest_implementation_report(task_root: Path) -> str:
|
|
208
197
|
reports = sorted(
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
16
|
from . import stage_targets
|
|
17
|
-
from .design_prep import DesignPrepDecision, resolve_design_prep
|
|
17
|
+
from .design_prep import DesignPrepDecision, DesignPrepError, resolve_design_prep
|
|
18
18
|
from .final_report_paths import final_report_data_path
|
|
19
19
|
from .stage_reconcile import auto_reconcile_best_effort
|
|
20
20
|
|
|
@@ -56,7 +56,12 @@ def _as_implementation_stage_error(exc: Exception) -> ImplementationStageError:
|
|
|
56
56
|
def _resolve_stage_design_prep(plan_path: Path, stage: int) -> DesignPrepDecision:
|
|
57
57
|
plan_data_path = final_report_data_path(plan_path)
|
|
58
58
|
if plan_data_path.is_file():
|
|
59
|
-
|
|
59
|
+
try:
|
|
60
|
+
return resolve_design_prep(plan_data_path, stage)
|
|
61
|
+
except DesignPrepError as exc:
|
|
62
|
+
# prepare catches ImplementationStageError → PrepareError; an
|
|
63
|
+
# untranslated DesignPrepError leaves okstra_ctl.run as a traceback.
|
|
64
|
+
raise _as_implementation_stage_error(exc) from exc
|
|
60
65
|
return DesignPrepDecision(
|
|
61
66
|
outcome="proceed",
|
|
62
67
|
effective_items=(),
|