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.
Files changed (71) hide show
  1. package/README.md +4 -1
  2. package/docs/architecture.md +22 -4
  3. package/docs/cli.md +53 -6
  4. package/docs/project-structure-overview.md +19 -9
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +5 -6
  8. package/runtime/bin/okstra-trace-cleanup.sh +28 -2
  9. package/runtime/prompts/lead/adapters/claude-code.md +3 -3
  10. package/runtime/prompts/lead/convergence.md +30 -5
  11. package/runtime/prompts/lead/okstra-lead-contract.md +9 -3
  12. package/runtime/prompts/lead/report-writer.md +20 -14
  13. package/runtime/prompts/lead/team-contract.md +3 -3
  14. package/runtime/prompts/profiles/_common-contract.md +1 -1
  15. package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
  16. package/runtime/prompts/profiles/feature-analysis.md +24 -0
  17. package/runtime/prompts/profiles/forbidden-actions.json +18 -0
  18. package/runtime/prompts/profiles/project-analysis.md +24 -0
  19. package/runtime/prompts/wizard/prompts.ko.json +44 -1
  20. package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
  21. package/runtime/python/okstra_ctl/analysis_packet.py +4 -10
  22. package/runtime/python/okstra_ctl/clarification_items.py +74 -1
  23. package/runtime/python/okstra_ctl/codex_dispatch.py +117 -58
  24. package/runtime/python/okstra_ctl/convergence_engine.py +3 -1
  25. package/runtime/python/okstra_ctl/dispatch_core.py +19 -56
  26. package/runtime/python/okstra_ctl/dispatch_state.py +167 -3
  27. package/runtime/python/okstra_ctl/path_hints.py +6 -0
  28. package/runtime/python/okstra_ctl/paths.py +7 -44
  29. package/runtime/python/okstra_ctl/render.py +79 -4
  30. package/runtime/python/okstra_ctl/render_final_report.py +13 -4
  31. package/runtime/python/okstra_ctl/report_views.py +134 -3
  32. package/runtime/python/okstra_ctl/run.py +118 -0
  33. package/runtime/python/okstra_ctl/run_context.py +34 -2
  34. package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
  35. package/runtime/python/okstra_ctl/user_response.py +309 -3
  36. package/runtime/python/okstra_ctl/wizard.py +579 -32
  37. package/runtime/python/okstra_ctl/worker_liveness.py +84 -21
  38. package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
  39. package/runtime/python/okstra_ctl/worker_prompt_contract.py +57 -0
  40. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  41. package/runtime/python/okstra_ctl/worker_state.py +65 -0
  42. package/runtime/python/okstra_ctl/workflow.py +22 -0
  43. package/runtime/python/okstra_token_usage/antigravity.py +3 -0
  44. package/runtime/python/okstra_token_usage/codex.py +54 -23
  45. package/runtime/python/okstra_token_usage/collect.py +141 -33
  46. package/runtime/python/okstra_token_usage/paths.py +27 -0
  47. package/runtime/python/okstra_vendor/__init__.py +15 -2
  48. package/runtime/schemas/convergence-groups-v1.0.schema.json +0 -1
  49. package/runtime/schemas/final-report-v1.0.schema.json +849 -3
  50. package/runtime/skills/okstra-run/SKILL.md +27 -5
  51. package/runtime/skills/okstra-setup/references/project-config.md +13 -4
  52. package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
  53. package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
  54. package/runtime/templates/reports/final-report.template.md +220 -0
  55. package/runtime/templates/reports/i18n/en.json +8 -0
  56. package/runtime/templates/reports/i18n/ko.json +8 -0
  57. package/runtime/templates/reports/project-analysis-input.template.md +58 -0
  58. package/runtime/templates/reports/report.js +84 -5
  59. package/runtime/templates/reports/user-response.template.md +19 -1
  60. package/runtime/validators/lib/fixtures.sh +1 -1
  61. package/runtime/validators/validate-report-views.py +61 -7
  62. package/runtime/validators/validate-run.py +94 -1
  63. package/runtime/validators/validate_analysis_report.py +895 -0
  64. package/src/cli-registry.mjs +7 -10
  65. package/src/commands/execute/render-bundle.mjs +3 -0
  66. package/src/commands/execute/worker-state.mjs +29 -0
  67. package/src/commands/inspect/worker-liveness.mjs +5 -3
  68. package/src/commands/lifecycle/preflight.mjs +13 -3
  69. package/src/lib/runtime-readiness.mjs +90 -0
  70. package/runtime/python/okstra_ctl/phase_cleanup.py +0 -235
  71. 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
- * ``--prompt`` — a CLI-wrapper prompt-history path. Neither `<prompt>.log` nor
19
- `<prompt>.status.json` past the launch grace means the wrapper never ran —
20
- the dispatch itself failed, which no artifact otherwise distinguishes from a
21
- worker that has merely not finished.
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(prompt: Path, now: datetime, grace: float) -> dict:
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
- if not prompt.is_file():
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 {**probe, "state": "pending", "reason": "within launch grace"}
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"prompt was written (grace {int(grace)}s)"
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
- prompts: list[str],
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 += [probe_launch(Path(p), now, launch_grace) for p in prompts]
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("--prompt", action="append", default=[],
128
- help="CLI-wrapper (codex/antigravity) prompt-history path "
129
- "(repeatable). Only the wrappers write the artifacts "
130
- "this probe looks for, so passing an in-process "
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 not args.audit and not args.prompt:
141
- parser.error("pass at least one --audit or --prompt path")
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
- args.prompt,
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-results audit file."
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 TWO files:",
107
+ "You are the author of THREE files:",
108
108
  "- The final-report data.json at Result Path.",
109
- "- The worker-results audit file at Audit sidecar path.",
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"
@@ -14,6 +14,9 @@ from __future__ import annotations
14
14
  from pathlib import Path
15
15
 
16
16
 
17
+ PRINT_MODE_USAGE_NOTE = "N/A — print mode exposes no transcript"
18
+
19
+
17
20
  def antigravity_session_total(json_path: Path) -> dict:
18
21
  return {"totalTokens": 0, "available": False}
19
22
 
@@ -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 find_codex_sessions(cwd: Path, started_at: str, ended_at: str) -> list[Path]:
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 CODEX_SESSIONS.is_dir() or not started_at or not ended_at:
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
- for p in CODEX_SESSIONS.rglob("rollout-*.jsonl"):
62
- try:
63
- with p.open() as fh:
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
- candidates.append((ts, p))
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()