okstra 0.144.0 → 0.146.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 +4 -1
- package/docs/architecture.md +22 -4
- package/docs/cli.md +53 -6
- package/docs/project-structure-overview.md +19 -9
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +5 -6
- package/runtime/bin/okstra-trace-cleanup.sh +28 -2
- package/runtime/prompts/lead/adapters/claude-code.md +3 -3
- package/runtime/prompts/lead/convergence.md +30 -5
- package/runtime/prompts/lead/okstra-lead-contract.md +9 -3
- package/runtime/prompts/lead/report-writer.md +20 -14
- package/runtime/prompts/lead/team-contract.md +3 -3
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
- package/runtime/prompts/profiles/feature-analysis.md +24 -0
- package/runtime/prompts/profiles/forbidden-actions.json +18 -0
- package/runtime/prompts/profiles/project-analysis.md +24 -0
- package/runtime/prompts/wizard/prompts.ko.json +44 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +4 -10
- package/runtime/python/okstra_ctl/clarification_items.py +74 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +117 -58
- package/runtime/python/okstra_ctl/convergence_engine.py +3 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +19 -56
- package/runtime/python/okstra_ctl/dispatch_state.py +167 -3
- package/runtime/python/okstra_ctl/path_hints.py +6 -0
- package/runtime/python/okstra_ctl/paths.py +7 -44
- package/runtime/python/okstra_ctl/render.py +79 -4
- package/runtime/python/okstra_ctl/render_final_report.py +13 -4
- package/runtime/python/okstra_ctl/report_views.py +134 -3
- package/runtime/python/okstra_ctl/run.py +118 -0
- package/runtime/python/okstra_ctl/run_context.py +34 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
- package/runtime/python/okstra_ctl/user_response.py +309 -3
- package/runtime/python/okstra_ctl/wizard.py +579 -32
- package/runtime/python/okstra_ctl/worker_liveness.py +84 -21
- package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +57 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
- package/runtime/python/okstra_ctl/worker_state.py +65 -0
- package/runtime/python/okstra_ctl/workflow.py +22 -0
- package/runtime/python/okstra_token_usage/antigravity.py +3 -0
- package/runtime/python/okstra_token_usage/codex.py +54 -23
- package/runtime/python/okstra_token_usage/collect.py +141 -33
- package/runtime/python/okstra_token_usage/paths.py +27 -0
- package/runtime/python/okstra_vendor/__init__.py +15 -2
- package/runtime/schemas/convergence-groups-v1.0.schema.json +0 -1
- package/runtime/schemas/final-report-v1.0.schema.json +849 -3
- package/runtime/skills/okstra-run/SKILL.md +27 -5
- package/runtime/skills/okstra-setup/references/project-config.md +13 -4
- package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
- package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
- package/runtime/templates/reports/final-report.template.md +220 -0
- package/runtime/templates/reports/i18n/en.json +8 -0
- package/runtime/templates/reports/i18n/ko.json +8 -0
- package/runtime/templates/reports/project-analysis-input.template.md +58 -0
- package/runtime/templates/reports/report.js +84 -5
- package/runtime/templates/reports/user-response.template.md +19 -1
- package/runtime/validators/lib/fixtures.sh +1 -1
- package/runtime/validators/validate-report-views.py +61 -7
- package/runtime/validators/validate-run.py +94 -1
- package/runtime/validators/validate_analysis_report.py +895 -0
- package/src/cli-registry.mjs +7 -10
- package/src/commands/execute/render-bundle.mjs +3 -0
- package/src/commands/execute/worker-state.mjs +29 -0
- package/src/commands/inspect/worker-liveness.mjs +5 -3
- package/src/commands/lifecycle/preflight.mjs +13 -3
- package/src/lib/runtime-readiness.mjs +90 -0
- package/runtime/python/okstra_ctl/phase_cleanup.py +0 -235
- package/src/commands/execute/phase-cleanup.mjs +0 -38
|
@@ -15,10 +15,10 @@ Two probes, matching the two ways a pending worker goes quiet:
|
|
|
15
15
|
* ``--audit`` — an in-process worker audit sidecar. Stale past the heartbeat
|
|
16
16
|
cadence (or present with no heartbeat at all) means the worker hung. Uses
|
|
17
17
|
the same line shape and budget the Phase 7 validator applies.
|
|
18
|
-
* ``--
|
|
19
|
-
`<prompt>.status.json` past the launch grace means
|
|
20
|
-
the
|
|
21
|
-
|
|
18
|
+
* ``--team-state`` + ``--worker`` — a CLI-wrapper assignment. Neither the
|
|
19
|
+
worker's `<prompt>.log` nor `<prompt>.status.json` past the launch grace means
|
|
20
|
+
the wrapper never ran. The grace starts at the persisted dispatch timestamp,
|
|
21
|
+
not when the prompt was materialized.
|
|
22
22
|
"""
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
@@ -28,6 +28,7 @@ import sys
|
|
|
28
28
|
from datetime import datetime, timezone
|
|
29
29
|
from pathlib import Path
|
|
30
30
|
|
|
31
|
+
from okstra_ctl.dispatch_state import DispatchError, load_json_object
|
|
31
32
|
from okstra_ctl.worker_heartbeat import (
|
|
32
33
|
HEARTBEAT_MAX_GAP_SECONDS,
|
|
33
34
|
latest_heartbeat,
|
|
@@ -80,43 +81,97 @@ def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
|
|
83
|
-
def probe_launch(
|
|
84
|
+
def probe_launch(
|
|
85
|
+
prompt: Path, dispatched_at: datetime, now: datetime, grace: float
|
|
86
|
+
) -> dict:
|
|
84
87
|
"""Whether the wrapper behind *prompt* ever started."""
|
|
85
88
|
log, status = _log_path(prompt), Path(f"{prompt}.status.json")
|
|
86
89
|
probe = {"kind": "launch", "path": str(prompt)}
|
|
87
90
|
if log.exists() or status.exists():
|
|
88
91
|
return {**probe, "state": "live", "reason": ""}
|
|
89
|
-
|
|
90
|
-
return {**probe, "state": "pending", "reason": "prompt history not written yet"}
|
|
91
|
-
waited = now.timestamp() - prompt.stat().st_mtime
|
|
92
|
+
waited = (now - dispatched_at).total_seconds()
|
|
92
93
|
if waited <= grace:
|
|
93
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
**probe,
|
|
96
|
+
"state": "pending",
|
|
97
|
+
"waitedSeconds": int(waited),
|
|
98
|
+
"reason": "within launch grace",
|
|
99
|
+
}
|
|
94
100
|
return {
|
|
95
101
|
**probe,
|
|
96
102
|
"state": "did-not-launch",
|
|
97
103
|
"waitedSeconds": int(waited),
|
|
98
104
|
"reason": (
|
|
99
105
|
f"neither {log.name} nor {status.name} exists {int(waited)}s after the "
|
|
100
|
-
f"
|
|
106
|
+
f"dispatch started (grace {int(grace)}s)"
|
|
101
107
|
),
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
|
|
105
111
|
def probe_all(
|
|
106
112
|
audits: list[str],
|
|
107
|
-
|
|
113
|
+
launches: list[tuple[str, datetime]],
|
|
108
114
|
*,
|
|
109
115
|
now: datetime,
|
|
110
116
|
max_idle: float,
|
|
111
117
|
launch_grace: float,
|
|
112
118
|
) -> dict:
|
|
113
119
|
probes = [probe_heartbeat(Path(p), now, max_idle) for p in audits]
|
|
114
|
-
probes += [
|
|
120
|
+
probes += [
|
|
121
|
+
probe_launch(Path(prompt), dispatched_at, now, launch_grace)
|
|
122
|
+
for prompt, dispatched_at in launches
|
|
123
|
+
]
|
|
115
124
|
unhealthy = [p for p in probes if p["state"] in ("stalled", "did-not-launch")]
|
|
116
125
|
return {"ok": not unhealthy, "checkedAt": now.isoformat(), "probes": probes,
|
|
117
126
|
"unhealthy": unhealthy}
|
|
118
127
|
|
|
119
128
|
|
|
129
|
+
def _parse_utc(value: object, label: str) -> datetime:
|
|
130
|
+
if not isinstance(value, str) or not value:
|
|
131
|
+
raise DispatchError(f"{label} must be a UTC ISO timestamp")
|
|
132
|
+
try:
|
|
133
|
+
instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
raise DispatchError(f"{label} must be a UTC ISO timestamp") from exc
|
|
136
|
+
if instant.tzinfo is None or instant.utcoffset() != timezone.utc.utcoffset(instant):
|
|
137
|
+
raise DispatchError(f"{label} must be a UTC ISO timestamp")
|
|
138
|
+
return instant.astimezone(timezone.utc)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _project_root_for_team_state(team_state_path: Path) -> Path:
|
|
142
|
+
for parent in team_state_path.resolve().parents:
|
|
143
|
+
if parent.name == ".okstra":
|
|
144
|
+
return parent.parent
|
|
145
|
+
raise DispatchError(
|
|
146
|
+
f"team-state is outside a project .okstra directory: {team_state_path}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _launch_target(team_state_value: str, worker_id: str) -> tuple[str, datetime]:
|
|
151
|
+
team_state_path = Path(team_state_value).resolve()
|
|
152
|
+
state = load_json_object(team_state_path, "team-state")
|
|
153
|
+
workers = state.get("workers")
|
|
154
|
+
if not isinstance(workers, list):
|
|
155
|
+
raise DispatchError(f"team-state workers must be an array: {team_state_path}")
|
|
156
|
+
worker = next(
|
|
157
|
+
(
|
|
158
|
+
row for row in workers
|
|
159
|
+
if isinstance(row, dict) and row.get("workerId") == worker_id
|
|
160
|
+
),
|
|
161
|
+
None,
|
|
162
|
+
)
|
|
163
|
+
if worker is None:
|
|
164
|
+
raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
|
|
165
|
+
prompt_value = worker.get("promptPath")
|
|
166
|
+
if not isinstance(prompt_value, str) or not prompt_value.strip():
|
|
167
|
+
raise DispatchError(f"worker {worker_id} has no promptPath")
|
|
168
|
+
prompt = Path(prompt_value)
|
|
169
|
+
if not prompt.is_absolute():
|
|
170
|
+
prompt = _project_root_for_team_state(team_state_path) / prompt
|
|
171
|
+
dispatched_at = _parse_utc(worker.get("startedAt"), f"worker {worker_id} startedAt")
|
|
172
|
+
return str(prompt), dispatched_at
|
|
173
|
+
|
|
174
|
+
|
|
120
175
|
def main(argv: list[str] | None = None) -> int:
|
|
121
176
|
parser = argparse.ArgumentParser(
|
|
122
177
|
prog="okstra worker-liveness",
|
|
@@ -124,12 +179,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
124
179
|
)
|
|
125
180
|
parser.add_argument("--audit", action="append", default=[],
|
|
126
181
|
help="in-process worker audit sidecar path (repeatable)")
|
|
127
|
-
parser.add_argument("--
|
|
128
|
-
help="CLI-wrapper (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"claude-worker path here always reports "
|
|
132
|
-
"did-not-launch — probe those with --audit")
|
|
182
|
+
parser.add_argument("--team-state", action="append", default=[],
|
|
183
|
+
help="team-state path for a CLI-wrapper assignment (repeatable)")
|
|
184
|
+
parser.add_argument("--worker", action="append", default=[],
|
|
185
|
+
help="worker id paired with --team-state (repeatable)")
|
|
133
186
|
parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
|
|
134
187
|
help="heartbeat staleness budget in seconds")
|
|
135
188
|
parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
|
|
@@ -137,12 +190,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
137
190
|
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
138
191
|
args = parser.parse_args(argv)
|
|
139
192
|
|
|
140
|
-
if
|
|
141
|
-
parser.error("
|
|
193
|
+
if len(args.team_state) != len(args.worker):
|
|
194
|
+
parser.error("each --team-state must have one paired --worker")
|
|
195
|
+
if not args.audit and not args.team_state:
|
|
196
|
+
parser.error("pass at least one --audit or --team-state/--worker pair")
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
launches = [
|
|
200
|
+
_launch_target(team_state, worker)
|
|
201
|
+
for team_state, worker in zip(args.team_state, args.worker, strict=True)
|
|
202
|
+
]
|
|
203
|
+
except DispatchError as exc:
|
|
204
|
+
parser.error(str(exc))
|
|
142
205
|
|
|
143
206
|
result = probe_all(
|
|
144
207
|
args.audit,
|
|
145
|
-
|
|
208
|
+
launches,
|
|
146
209
|
now=datetime.now(timezone.utc),
|
|
147
210
|
max_idle=args.max_idle,
|
|
148
211
|
launch_grace=args.launch_grace,
|
|
@@ -90,8 +90,8 @@ def report_writer_prompt_body(
|
|
|
90
90
|
"",
|
|
91
91
|
"## Role",
|
|
92
92
|
(
|
|
93
|
-
"You are the Report writer worker. Author the final-report data.json "
|
|
94
|
-
"and the worker-
|
|
93
|
+
"You are the Report writer worker. Author the final-report data.json, "
|
|
94
|
+
"its rendered Markdown sibling, and the worker-result pointer."
|
|
95
95
|
),
|
|
96
96
|
"",
|
|
97
97
|
"## Task",
|
|
@@ -104,9 +104,15 @@ def report_writer_prompt_body(
|
|
|
104
104
|
mcp_pointer_line(),
|
|
105
105
|
"",
|
|
106
106
|
"## Output Contract",
|
|
107
|
-
"You are the author of
|
|
107
|
+
"You are the author of THREE files:",
|
|
108
108
|
"- The final-report data.json at Result Path.",
|
|
109
|
-
"- The
|
|
109
|
+
"- The rendered Markdown sibling produced through okstra render-final-report.",
|
|
110
|
+
"- The worker-result pointer at Worker Result Path.",
|
|
111
|
+
(
|
|
112
|
+
"Keep the pointer to three entries: the data.json path, rendered "
|
|
113
|
+
"Markdown path, and Convergence state input path."
|
|
114
|
+
),
|
|
115
|
+
"Maintain the separate audit sidecar at Audit sidecar path.",
|
|
110
116
|
(
|
|
111
117
|
'After writing the data.json, invoke "okstra render-final-report '
|
|
112
118
|
'<Result Path>" so the markdown sibling is rendered before you return.'
|
|
@@ -129,6 +135,7 @@ def report_writer_input_lines(
|
|
|
129
135
|
("Clarification response", instruction_path(manifest, active_context, "clarificationResponsePath")),
|
|
130
136
|
("Final report template", instruction_path(manifest, active_context, "finalReportTemplatePath")),
|
|
131
137
|
("Final report schema", instruction_path(manifest, active_context, "finalReportSchemaPath")),
|
|
138
|
+
("Convergence state", run_path(manifest, active_context, "convergenceStatePath")),
|
|
132
139
|
("Worker results directory", _string_value(manifest.get("workerResultsDirectoryPath"))),
|
|
133
140
|
]
|
|
134
141
|
lines = existing_input_lines(inputs)
|
|
@@ -171,6 +178,19 @@ def instruction_path(
|
|
|
171
178
|
return _string_value(manifest.get(key))
|
|
172
179
|
|
|
173
180
|
|
|
181
|
+
def run_path(
|
|
182
|
+
manifest: Mapping[str, Any],
|
|
183
|
+
active_context: Mapping[str, Any],
|
|
184
|
+
key: str,
|
|
185
|
+
) -> str:
|
|
186
|
+
run = active_context.get("run")
|
|
187
|
+
if isinstance(run, Mapping):
|
|
188
|
+
value = _string_value(run.get(key))
|
|
189
|
+
if value:
|
|
190
|
+
return value
|
|
191
|
+
return _string_value(manifest.get(key))
|
|
192
|
+
|
|
193
|
+
|
|
174
194
|
def mcp_pointer_line() -> str:
|
|
175
195
|
return (
|
|
176
196
|
'**MCP servers:** follow the task brief\'s "## Available MCP Servers" '
|
|
@@ -19,6 +19,8 @@ MAX_FINAL_VERIFICATION_BODY_LINES = 96
|
|
|
19
19
|
PROMPT_DELIVERY_MODE_HEADER = "**Prompt Delivery Mode:**"
|
|
20
20
|
PROMPT_DELIVERY_MODES = frozenset({"eager-include", "lazy-path-reference"})
|
|
21
21
|
MODEL_HEADER = "**Model:**"
|
|
22
|
+
TASK_TYPE_HEADER = "**Task Type:**"
|
|
23
|
+
FORBIDDEN_ACTIONS_HEADER = "**Forbidden actions:**"
|
|
22
24
|
|
|
23
25
|
_DIRECTIVE_HEADING = "## Run-specific directive"
|
|
24
26
|
_WORKER_ERROR_CONTRACT_HEADER = "**Worker Error Contract Path:**"
|
|
@@ -177,6 +179,61 @@ def validate_analysis_prompt_set(prompts: Mapping[str, str]) -> list[str]:
|
|
|
177
179
|
return [f"normalized initial analysis prompts differ across workers: {workers}"]
|
|
178
180
|
|
|
179
181
|
|
|
182
|
+
def validate_reverify_prompt(
|
|
183
|
+
text: str,
|
|
184
|
+
*,
|
|
185
|
+
task_type: str,
|
|
186
|
+
forbidden_actions: str,
|
|
187
|
+
) -> list[str]:
|
|
188
|
+
"""Require the active phase boundary in a lightweight reverify prompt."""
|
|
189
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
190
|
+
errors: list[str] = []
|
|
191
|
+
task_values = _header_values(normalized, TASK_TYPE_HEADER)
|
|
192
|
+
if task_values != [task_type]:
|
|
193
|
+
errors.append(
|
|
194
|
+
f"exactly one {TASK_TYPE_HEADER} {task_type} header is required"
|
|
195
|
+
)
|
|
196
|
+
action_blocks = _section_values(normalized, FORBIDDEN_ACTIONS_HEADER)
|
|
197
|
+
if len(action_blocks) != 1:
|
|
198
|
+
errors.append("exactly one **Forbidden actions:** block is required")
|
|
199
|
+
elif action_blocks[0] != forbidden_actions.strip():
|
|
200
|
+
errors.append(
|
|
201
|
+
"Forbidden actions block must exactly match active-run-context "
|
|
202
|
+
"workflow.forbiddenActions"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
expected_task_header = f"{TASK_TYPE_HEADER} {task_type}"
|
|
206
|
+
boundary_position = normalized.find(expected_task_header)
|
|
207
|
+
read_scope_position = normalized.find("**Read scope:**")
|
|
208
|
+
if boundary_position >= 0 and (
|
|
209
|
+
read_scope_position < 0 or read_scope_position > boundary_position
|
|
210
|
+
):
|
|
211
|
+
errors.append("phase boundary block must follow the reverify anchor headers")
|
|
212
|
+
first_heading = re.search(r"(?m)^##\s+", normalized)
|
|
213
|
+
if (
|
|
214
|
+
boundary_position >= 0
|
|
215
|
+
and first_heading is not None
|
|
216
|
+
and boundary_position > first_heading.start()
|
|
217
|
+
):
|
|
218
|
+
errors.append("phase boundary block must precede reverify instructions")
|
|
219
|
+
return errors
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _section_values(text: str, header: str) -> list[str]:
|
|
223
|
+
lines = text.splitlines()
|
|
224
|
+
values: list[str] = []
|
|
225
|
+
for index, line in enumerate(lines):
|
|
226
|
+
if line.strip() != header:
|
|
227
|
+
continue
|
|
228
|
+
body: list[str] = []
|
|
229
|
+
for candidate in lines[index + 1:]:
|
|
230
|
+
stripped = candidate.strip()
|
|
231
|
+
if stripped.startswith("## ") or re.match(r"^\*\*[^*]+:\*\*", stripped):
|
|
232
|
+
break
|
|
233
|
+
body.append(candidate)
|
|
234
|
+
values.append("\n".join(body).strip())
|
|
235
|
+
return values
|
|
236
|
+
|
|
180
237
|
|
|
181
238
|
def validate_initial_prompt_records(
|
|
182
239
|
*,
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from typing import Any, Literal, Mapping
|
|
6
6
|
|
|
7
|
+
from .analysis_inputs import ANALYSIS_TASK_TYPES
|
|
8
|
+
|
|
7
9
|
|
|
8
10
|
PromptAudience = Literal[
|
|
9
11
|
"analysis",
|
|
@@ -41,6 +43,7 @@ SUPPORTED_TASK_TYPES = frozenset({
|
|
|
41
43
|
"implementation",
|
|
42
44
|
"final-verification",
|
|
43
45
|
"release-handoff",
|
|
46
|
+
*ANALYSIS_TASK_TYPES,
|
|
44
47
|
})
|
|
45
48
|
WORKER_PREAMBLE_FILENAME_BY_AUDIENCE = {
|
|
46
49
|
"analysis": "worker-prompt-preamble.md",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Authoritative worker status transitions for one persisted team-state."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .dispatch_state import DispatchError, WORKER_STATUSES, transition_worker_status
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _validated_team_state_path(value: str) -> Path:
|
|
13
|
+
path = Path(value).resolve()
|
|
14
|
+
if not any(parent.name == ".okstra" for parent in (path.parent, *path.parents)):
|
|
15
|
+
raise DispatchError(
|
|
16
|
+
f"team-state is outside a project .okstra directory: {path}"
|
|
17
|
+
)
|
|
18
|
+
return path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(prog="okstra worker-state")
|
|
23
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
transition = commands.add_parser(
|
|
25
|
+
"transition", help="atomically transition one worker status"
|
|
26
|
+
)
|
|
27
|
+
transition.add_argument("--team-state", required=True)
|
|
28
|
+
transition.add_argument("--worker", required=True)
|
|
29
|
+
transition.add_argument("--status", required=True, choices=sorted(WORKER_STATUSES))
|
|
30
|
+
transition.add_argument("--reason", default="")
|
|
31
|
+
transition.add_argument("--model", default="")
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv: list[str] | None = None) -> int:
|
|
36
|
+
parser = _parser()
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
try:
|
|
39
|
+
team_state_path = _validated_team_state_path(args.team_state)
|
|
40
|
+
transition_worker_status(
|
|
41
|
+
team_state_path,
|
|
42
|
+
args.worker,
|
|
43
|
+
args.status,
|
|
44
|
+
args.reason,
|
|
45
|
+
model_execution_value=args.model,
|
|
46
|
+
)
|
|
47
|
+
except DispatchError as exc:
|
|
48
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
print(
|
|
51
|
+
json.dumps(
|
|
52
|
+
{
|
|
53
|
+
"ok": True,
|
|
54
|
+
"teamStatePath": str(team_state_path),
|
|
55
|
+
"workerId": args.worker,
|
|
56
|
+
"status": args.status,
|
|
57
|
+
},
|
|
58
|
+
ensure_ascii=False,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -12,6 +12,8 @@ from __future__ import annotations
|
|
|
12
12
|
import json
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
+
from okstra_ctl.analysis_inputs import ANALYSIS_TASK_TYPES
|
|
16
|
+
|
|
15
17
|
PHASE_SEQUENCE = [
|
|
16
18
|
"requirements-discovery",
|
|
17
19
|
"error-analysis",
|
|
@@ -24,6 +26,7 @@ PHASE_SEQUENCE = [
|
|
|
24
26
|
DEFAULT_NEXT_PHASE = {
|
|
25
27
|
"requirements-discovery": "pending-routing-decision",
|
|
26
28
|
"improvement-discovery": "pending-routing-decision",
|
|
29
|
+
**{task_type: "pending-routing-decision" for task_type in ANALYSIS_TASK_TYPES},
|
|
27
30
|
"error-analysis": "implementation-planning",
|
|
28
31
|
"implementation-planning": "implementation",
|
|
29
32
|
"implementation": "final-verification",
|
|
@@ -56,6 +59,25 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
56
59
|
" - Phase 5.5 consensus classification (full / partial / contested / worker-unique)"
|
|
57
60
|
),
|
|
58
61
|
},
|
|
62
|
+
"project-analysis": {
|
|
63
|
+
"allowed": (
|
|
64
|
+
" - components, dependency directions, entry points, repositories, and external integrations\n"
|
|
65
|
+
" - shallow feature index and scan scope\n"
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
"feature-analysis": {
|
|
69
|
+
"allowed": (
|
|
70
|
+
" - confirmed target and normal, alternate, and failure flows\n"
|
|
71
|
+
" - rules, state transitions, external calls, and test coverage scope\n"
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
"change-impact-analysis": {
|
|
75
|
+
"allowed": (
|
|
76
|
+
" - preserved behavior, impacted items, and dependency propagation\n"
|
|
77
|
+
" - test and operational impact\n"
|
|
78
|
+
" - constraints and open decisions passed to implementation-planning\n"
|
|
79
|
+
),
|
|
80
|
+
},
|
|
59
81
|
"error-analysis": {
|
|
60
82
|
"allowed": (
|
|
61
83
|
" - symptom and trigger evidence\n"
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from .jsonl_io import iter_jsonl
|
|
7
|
-
from .paths import CODEX_SESSIONS, ts_in_window
|
|
8
|
+
from .paths import CODEX_SESSIONS, codex_session_roots, ts_in_window
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_DEFAULT_CODEX_SESSIONS = CODEX_SESSIONS
|
|
8
12
|
|
|
9
13
|
|
|
10
14
|
def codex_session_total(jsonl_path: Path) -> dict:
|
|
@@ -52,33 +56,60 @@ def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None
|
|
|
52
56
|
return sessions[-1] if sessions else None
|
|
53
57
|
|
|
54
58
|
|
|
55
|
-
def
|
|
59
|
+
def _session_metadata(path: Path) -> tuple[str, str] | None:
|
|
60
|
+
try:
|
|
61
|
+
with path.open() as fh:
|
|
62
|
+
first = fh.readline()
|
|
63
|
+
except OSError:
|
|
64
|
+
return None
|
|
65
|
+
if not first:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
record = json.loads(first)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
return None
|
|
71
|
+
if record.get("type") != "session_meta":
|
|
72
|
+
return None
|
|
73
|
+
payload = record.get("payload") or {}
|
|
74
|
+
timestamp = payload.get("timestamp") or record.get("timestamp") or ""
|
|
75
|
+
return str(payload.get("cwd") or ""), timestamp
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_codex_sessions(
|
|
79
|
+
cwd: Path,
|
|
80
|
+
started_at: str,
|
|
81
|
+
ended_at: str,
|
|
82
|
+
*,
|
|
83
|
+
session_roots: tuple[Path, ...] | None = None,
|
|
84
|
+
) -> list[Path]:
|
|
56
85
|
"""Find codex rollout jsonls whose meta.cwd matches the window."""
|
|
57
|
-
if not
|
|
86
|
+
if not started_at or not ended_at:
|
|
58
87
|
return []
|
|
88
|
+
if session_roots is None:
|
|
89
|
+
if CODEX_SESSIONS != _DEFAULT_CODEX_SESSIONS:
|
|
90
|
+
session_roots = (CODEX_SESSIONS,)
|
|
91
|
+
else:
|
|
92
|
+
session_roots = codex_session_roots(Path.home(), os.environ)
|
|
59
93
|
target_cwd = str(cwd)
|
|
60
94
|
candidates: list[tuple[str, Path]] = []
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
first = fh.readline()
|
|
65
|
-
except OSError:
|
|
66
|
-
continue
|
|
67
|
-
if not first:
|
|
68
|
-
continue
|
|
69
|
-
try:
|
|
70
|
-
rec = json.loads(first)
|
|
71
|
-
except json.JSONDecodeError:
|
|
72
|
-
continue
|
|
73
|
-
if rec.get("type") != "session_meta":
|
|
74
|
-
continue
|
|
75
|
-
payload = rec.get("payload") or {}
|
|
76
|
-
if payload.get("cwd") != target_cwd:
|
|
77
|
-
continue
|
|
78
|
-
ts = payload.get("timestamp") or rec.get("timestamp") or ""
|
|
79
|
-
if not ts_in_window(ts, started_at, ended_at):
|
|
95
|
+
seen: set[Path] = set()
|
|
96
|
+
for root in session_roots:
|
|
97
|
+
if not root.is_dir():
|
|
80
98
|
continue
|
|
81
|
-
|
|
99
|
+
for p in root.rglob("rollout-*.jsonl"):
|
|
100
|
+
identity = p.resolve()
|
|
101
|
+
if identity in seen:
|
|
102
|
+
continue
|
|
103
|
+
seen.add(identity)
|
|
104
|
+
metadata = _session_metadata(p)
|
|
105
|
+
if metadata is None:
|
|
106
|
+
continue
|
|
107
|
+
session_cwd, ts = metadata
|
|
108
|
+
if session_cwd != target_cwd:
|
|
109
|
+
continue
|
|
110
|
+
if not ts_in_window(ts, started_at, ended_at):
|
|
111
|
+
continue
|
|
112
|
+
candidates.append((ts, p))
|
|
82
113
|
if not candidates:
|
|
83
114
|
return []
|
|
84
115
|
candidates.sort()
|