okstra 0.170.3 → 0.172.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.md +13 -0
- package/docs/cli.md +4 -2
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/project-structure-overview.md +3 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +4 -0
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +36 -12
- package/runtime/prompts/lead/plan-body-verification.md +22 -11
- package/runtime/prompts/lead/report-writer.md +11 -10
- package/runtime/prompts/lead/team-contract.md +2 -0
- package/runtime/prompts/profiles/_clarification-recommendation.md +3 -1
- package/runtime/prompts/profiles/_common-contract.md +2 -1
- package/runtime/prompts/profiles/implementation-planning.md +8 -1
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
- package/runtime/python/okstra_ctl/agent_activity.py +306 -0
- package/runtime/python/okstra_ctl/clarification_items.py +37 -20
- package/runtime/python/okstra_ctl/cmux.py +144 -59
- package/runtime/python/okstra_ctl/lead_events.py +47 -4
- package/runtime/python/okstra_ctl/render.py +11 -3
- package/runtime/python/okstra_ctl/report_finalize.py +51 -14
- package/runtime/python/okstra_ctl/report_html/common.py +5 -3
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +17 -1
- package/runtime/python/okstra_ctl/report_translation.py +14 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +150 -0
- package/runtime/schemas/final-report-v2.0.schema.json +189 -0
- package/runtime/skills/okstra-user-response/SKILL.md +2 -2
- package/runtime/templates/reports/final-report-v2.template.md +8 -0
- package/runtime/templates/reports/html/assets/base.css +7 -0
- package/runtime/templates/reports/html/i18n/en.json +6 -1
- package/runtime/templates/reports/html/i18n/ko.json +6 -1
- package/runtime/templates/reports/html/macros/forms.html +21 -2
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +25 -0
- package/runtime/templates/reports/i18n/en.json +4 -0
- package/runtime/templates/reports/report.js +26 -17
- package/runtime/templates/reports/user-response.template.md +3 -1
- package/runtime/templates/worker-prompt-preamble.md +8 -0
- package/runtime/validators/validate-run.py +989 -29
- package/runtime/validators/validate_session_conformance.py +523 -35
- package/src/cli-registry.mjs +7 -0
- package/src/commands/report/agent-activity.mjs +21 -0
|
@@ -30,17 +30,26 @@ LOGIN_SHELL_TIMEOUT_SECONDS = 15
|
|
|
30
30
|
# re-enters cmux's own agent wrapper instead of the real CLI.
|
|
31
31
|
SHIM_DIR_MARKER = "cmux-cli-shims"
|
|
32
32
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
|
|
33
|
+
# The workspace is split in fifths: two for the lead, three for the workers.
|
|
34
|
+
# Every worker lands in that one column and the column divides downward, so all
|
|
35
|
+
# workers hold the same width and only one border — the lead's — is ever
|
|
36
|
+
# computed. Splitting sideways instead would make each worker's width a
|
|
37
|
+
# function of how many rounds preceded it.
|
|
38
|
+
LEAD_SHARE_WITH_WORKERS = 2 / 5
|
|
39
|
+
|
|
40
|
+
# Three fifths is what a worker needs to be worth watching: measured against a
|
|
41
|
+
# Claude Code worker, 67 columns renders losslessly and 33 drops content off the
|
|
42
|
+
# right edge, and three fifths clears 67 on any window wide enough to hold two
|
|
43
|
+
# panes at all.
|
|
44
|
+
#
|
|
45
|
+
# With no workers on screen the lead has nothing to share with, so it takes the
|
|
46
|
+
# whole workspace back rather than sitting at its working width.
|
|
47
|
+
LEAD_SHARE_ALONE = 1.0
|
|
48
|
+
|
|
49
|
+
# A floor for the dimension the worker column actually divides. A pane
|
|
50
|
+
# this short still shows a command and its first lines of output; below it the
|
|
51
|
+
# pane stops being a window onto the worker and the next one stacks as a tab.
|
|
52
|
+
WORKER_MIN_ROWS = 20
|
|
44
53
|
|
|
45
54
|
# Sidebar entries are keyed by source so tools do not overwrite each other's.
|
|
46
55
|
SIDEBAR_SOURCE = "okstra"
|
|
@@ -151,6 +160,7 @@ class PaneGeometry:
|
|
|
151
160
|
x: int
|
|
152
161
|
y: int
|
|
153
162
|
cell_width_points: int
|
|
163
|
+
width_points: float = 0.0
|
|
154
164
|
ref: str = ""
|
|
155
165
|
selected_surface_id: str = ""
|
|
156
166
|
|
|
@@ -169,10 +179,16 @@ def plan_worker_placement(
|
|
|
169
179
|
*,
|
|
170
180
|
lead_pane_id: str,
|
|
171
181
|
owned_surface_ids: Collection[str],
|
|
172
|
-
|
|
182
|
+
min_rows: int,
|
|
173
183
|
) -> Placement:
|
|
174
184
|
"""Pick the next worker slot from the workspace's current geometry.
|
|
175
185
|
|
|
186
|
+
One column for every worker: the first split takes it off the lead, and each
|
|
187
|
+
one after that divides the column downward. Width is therefore decided once,
|
|
188
|
+
by where the lead's border sits, and every worker inherits it — nothing here
|
|
189
|
+
computes a width, and no worker's width depends on how many rounds ran
|
|
190
|
+
before it.
|
|
191
|
+
|
|
176
192
|
Stateless by design: okstra records surface UUIDs, never a layout, so a
|
|
177
193
|
resumed or crashed run cannot carry a layout model that no longer matches
|
|
178
194
|
the screen. Every dispatch re-reads the panes and derives the next slot.
|
|
@@ -180,9 +196,9 @@ def plan_worker_placement(
|
|
|
180
196
|
Those same UUIDs say which panes okstra may place into. A workspace also
|
|
181
197
|
holds panes okstra never opened — another agent session, a shell the user
|
|
182
198
|
keeps around — and "not the lead" does not make a pane a worker slot. Taken
|
|
183
|
-
as one, a stranger's pane is split or
|
|
184
|
-
|
|
185
|
-
|
|
199
|
+
as one, a stranger's pane is split or stacked into: the workers land as
|
|
200
|
+
background tabs in someone else's window, so nothing appears on screen and
|
|
201
|
+
that window grows tabs it did not ask for.
|
|
186
202
|
"""
|
|
187
203
|
workers = [
|
|
188
204
|
pane
|
|
@@ -192,14 +208,48 @@ def plan_worker_placement(
|
|
|
192
208
|
]
|
|
193
209
|
if not workers:
|
|
194
210
|
return Placement(pane_id=lead_pane_id, direction="right", stack_as_tab=False)
|
|
211
|
+
return _extend_the_worker_column(workers, min_rows=min_rows)
|
|
195
212
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
|
|
214
|
+
def lead_target_width(
|
|
215
|
+
panes: Sequence[PaneGeometry],
|
|
216
|
+
*,
|
|
217
|
+
lead_pane_id: str,
|
|
218
|
+
owned_surface_ids: Collection[str],
|
|
219
|
+
container_width_points: float,
|
|
220
|
+
) -> float:
|
|
221
|
+
"""How wide the lead should be, in the points `pixel_frame` reports.
|
|
222
|
+
|
|
223
|
+
Two fifths while workers are on screen, all of it when they are gone. The
|
|
224
|
+
share is taken of what okstra may actually place into, not of the window:
|
|
225
|
+
a workspace can hold panes okstra never opened, and handing the lead the
|
|
226
|
+
whole container would shove those off their own width. Their width is
|
|
227
|
+
subtracted first and the share applies to the remainder.
|
|
228
|
+
|
|
229
|
+
Deriving the target from the container rather than from a fixed column count
|
|
230
|
+
is what keeps the split honest at any window size — a hardcoded 80 columns
|
|
231
|
+
is two fifths of one particular display and an arbitrary slice of every
|
|
232
|
+
other.
|
|
233
|
+
"""
|
|
234
|
+
strangers = sum(
|
|
235
|
+
pane.width_points
|
|
236
|
+
for pane in panes
|
|
237
|
+
if pane.pane_id != lead_pane_id
|
|
238
|
+
and not _holds_an_okstra_surface(pane, owned_surface_ids)
|
|
239
|
+
)
|
|
240
|
+
usable = container_width_points - strangers
|
|
241
|
+
if usable <= 0:
|
|
242
|
+
return 0.0
|
|
243
|
+
workers_on_screen = any(
|
|
244
|
+
pane.pane_id != lead_pane_id
|
|
245
|
+
and _holds_an_okstra_surface(pane, owned_surface_ids)
|
|
246
|
+
for pane in panes
|
|
247
|
+
)
|
|
248
|
+
share = LEAD_SHARE_WITH_WORKERS if workers_on_screen else LEAD_SHARE_ALONE
|
|
249
|
+
return usable * share
|
|
200
250
|
|
|
201
251
|
|
|
202
|
-
def lead_resize_points(lead: PaneGeometry, *,
|
|
252
|
+
def lead_resize_points(lead: PaneGeometry, *, target_width_points: float) -> int:
|
|
203
253
|
"""How far to move the lead's right border, in the points `pane.resize` takes.
|
|
204
254
|
|
|
205
255
|
Signed: positive when the lead is too wide and the border comes in, negative
|
|
@@ -208,14 +258,14 @@ def lead_resize_points(lead: PaneGeometry, *, target_columns: int) -> int:
|
|
|
208
258
|
Both directions are needed. A split halves whatever pane it lands on, and
|
|
209
259
|
the first worker of every round lands on the lead — so a rule that only ever
|
|
210
260
|
shrinks leaves that half permanent, and the round after it takes half of
|
|
211
|
-
what is left. Measured on this display: 215 columns becomes
|
|
212
|
-
then
|
|
261
|
+
what is left. Measured on this display: 215 columns becomes 107, then 53,
|
|
262
|
+
then 26, until neither the lead nor its workers can be read.
|
|
213
263
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
264
|
+
Measured in the same points `pixel_frame` reports, so no cell-size
|
|
265
|
+
conversion happens here at all. A column count would need one, and would
|
|
266
|
+
round the target to a whole cell before the border ever moved.
|
|
217
267
|
"""
|
|
218
|
-
return (lead.
|
|
268
|
+
return round(lead.width_points - target_width_points)
|
|
219
269
|
|
|
220
270
|
|
|
221
271
|
def _holds_an_okstra_surface(
|
|
@@ -229,31 +279,22 @@ def _holds_an_okstra_surface(
|
|
|
229
279
|
return any(surface_id in owned_surface_ids for surface_id in pane.surface_ids)
|
|
230
280
|
|
|
231
281
|
|
|
232
|
-
def
|
|
233
|
-
workers: Sequence[PaneGeometry], *,
|
|
234
|
-
) -> Placement:
|
|
235
|
-
rightmost = max(workers, key=lambda pane: pane.x)
|
|
236
|
-
if rightmost.columns // GRID_COLUMNS >= min_columns:
|
|
237
|
-
return Placement(pane_id=rightmost.pane_id, direction="right", stack_as_tab=False)
|
|
238
|
-
roomiest = min(workers, key=lambda pane: (len(pane.surface_ids), pane.x))
|
|
239
|
-
return Placement(pane_id=roomiest.pane_id, direction="", stack_as_tab=True)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def _extend_the_shortest_column(
|
|
243
|
-
columns: dict[int, list[PaneGeometry]]
|
|
282
|
+
def _extend_the_worker_column(
|
|
283
|
+
workers: Sequence[PaneGeometry], *, min_rows: int
|
|
244
284
|
) -> Placement:
|
|
245
|
-
|
|
246
|
-
bottom = max(shortest, key=lambda pane: pane.y)
|
|
247
|
-
return Placement(pane_id=bottom.pane_id, direction="down", stack_as_tab=False)
|
|
285
|
+
"""Divide the bottom worker, or stack when the halves would be unreadable.
|
|
248
286
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
workers
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
287
|
+
A split halves the pane it lands on, so the bottom pane's own height decides
|
|
288
|
+
whether the column can take another worker at all. Once it cannot, further
|
|
289
|
+
workers stack as tabs into the pane holding the fewest — a background tab is
|
|
290
|
+
worse than a visible pane, but better than two panes too short to show a
|
|
291
|
+
command and its first lines of output.
|
|
292
|
+
"""
|
|
293
|
+
bottom = max(workers, key=lambda pane: pane.y)
|
|
294
|
+
if bottom.rows // 2 >= min_rows:
|
|
295
|
+
return Placement(pane_id=bottom.pane_id, direction="down", stack_as_tab=False)
|
|
296
|
+
roomiest = min(workers, key=lambda pane: (len(pane.surface_ids), pane.y))
|
|
297
|
+
return Placement(pane_id=roomiest.pane_id, direction="", stack_as_tab=True)
|
|
257
298
|
|
|
258
299
|
|
|
259
300
|
def shim_free_login_path() -> str:
|
|
@@ -298,8 +339,7 @@ def worker_command_line(
|
|
|
298
339
|
|
|
299
340
|
|
|
300
341
|
def list_panes(workspace: str) -> list[PaneGeometry]:
|
|
301
|
-
|
|
302
|
-
return [_pane_geometry(entry) for entry in payload.get("panes", [])]
|
|
342
|
+
return _panes_from(_pane_list_payload(workspace))
|
|
303
343
|
|
|
304
344
|
|
|
305
345
|
def spawn_worker_surface(
|
|
@@ -326,13 +366,13 @@ def spawn_worker_surface(
|
|
|
326
366
|
panes,
|
|
327
367
|
lead_pane_id=lead.pane_id,
|
|
328
368
|
owned_surface_ids=owned_surface_ids,
|
|
329
|
-
|
|
369
|
+
min_rows=WORKER_MIN_ROWS,
|
|
330
370
|
)
|
|
331
371
|
target = _pane_by_id(panes, placement.pane_id)
|
|
332
372
|
surface_uuid = _open_worker_surface(workspace, placement, target)
|
|
333
373
|
run_cmux(["rename-tab", "--surface", surface_uuid, "--title", title])
|
|
334
374
|
_exec_worker(surface_uuid, cwd=cwd, command=command)
|
|
335
|
-
_size_lead_pane(workspace)
|
|
375
|
+
_size_lead_pane(workspace, (*owned_surface_ids, surface_uuid))
|
|
336
376
|
return surface_uuid
|
|
337
377
|
|
|
338
378
|
|
|
@@ -358,11 +398,17 @@ def restore_lead_width() -> None:
|
|
|
358
398
|
holds a run manifest rather than the workspace UUID that `pane.resize`
|
|
359
399
|
needs. An unresolvable workspace means cmux is gone or was never there, and
|
|
360
400
|
there is no pane left to size.
|
|
401
|
+
|
|
402
|
+
Owning nothing is the point of the empty ledger. Teardown has just closed
|
|
403
|
+
every surface it opened, so no pane on screen is okstra's but the lead's —
|
|
404
|
+
which is what makes the lead's share the whole workspace. A pane teardown
|
|
405
|
+
failed to close counts as a stranger's and keeps its width, which is the
|
|
406
|
+
safe way to be wrong here.
|
|
361
407
|
"""
|
|
362
408
|
workspace = resolve_lead_workspace()
|
|
363
409
|
if not workspace:
|
|
364
410
|
return
|
|
365
|
-
_size_lead_pane(workspace)
|
|
411
|
+
_size_lead_pane(workspace, ())
|
|
366
412
|
|
|
367
413
|
|
|
368
414
|
def capture_surface(surface_uuid: str, *, last_lines: int = 200) -> str:
|
|
@@ -557,8 +603,12 @@ def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> Non
|
|
|
557
603
|
raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
|
|
558
604
|
|
|
559
605
|
|
|
560
|
-
def _size_lead_pane(workspace: str) -> None:
|
|
561
|
-
"""
|
|
606
|
+
def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
|
|
607
|
+
"""Move the lead's border to its share of the workspace.
|
|
608
|
+
|
|
609
|
+
This is the only border okstra places. Workers divide their column downward
|
|
610
|
+
and therefore all inherit whatever is left of it, so placing this one border
|
|
611
|
+
sizes every pane on screen.
|
|
562
612
|
|
|
563
613
|
Which pane carries the request follows from what `pane.resize` does: it
|
|
564
614
|
moves the named pane's own border in the direction given. The lead can push
|
|
@@ -568,11 +618,26 @@ def _size_lead_pane(workspace: str) -> None:
|
|
|
568
618
|
and widening is the lead's.
|
|
569
619
|
|
|
570
620
|
Run after every worker opens rather than once per round: the split that just
|
|
571
|
-
happened is what knocked the lead off its
|
|
621
|
+
happened is what knocked the lead off its share, and no other event does.
|
|
572
622
|
"""
|
|
573
|
-
|
|
623
|
+
payload = _pane_list_payload(workspace)
|
|
624
|
+
container_width = _container_width_points(payload)
|
|
625
|
+
if container_width <= 0:
|
|
626
|
+
# Without the frame the shares are taken of, there is no target to move
|
|
627
|
+
# toward — and a guessed one would move the border to a wrong place
|
|
628
|
+
# rather than leave it where the user last saw it.
|
|
629
|
+
return
|
|
630
|
+
panes = _panes_from(payload)
|
|
574
631
|
lead = _lead_pane(panes)
|
|
575
|
-
offset = lead_resize_points(
|
|
632
|
+
offset = lead_resize_points(
|
|
633
|
+
lead,
|
|
634
|
+
target_width_points=lead_target_width(
|
|
635
|
+
panes,
|
|
636
|
+
lead_pane_id=lead.pane_id,
|
|
637
|
+
owned_surface_ids=owned_surface_ids,
|
|
638
|
+
container_width_points=container_width,
|
|
639
|
+
),
|
|
640
|
+
)
|
|
576
641
|
if offset == 0:
|
|
577
642
|
return
|
|
578
643
|
neighbours = [pane for pane in panes if pane.x > lead.x]
|
|
@@ -609,6 +674,25 @@ def _pane_by_id(panes: Sequence[PaneGeometry], pane_id: str) -> PaneGeometry:
|
|
|
609
674
|
raise RuntimeError(f"cmux pane {pane_id} disappeared while placing a worker")
|
|
610
675
|
|
|
611
676
|
|
|
677
|
+
def _pane_list_payload(workspace: str) -> dict[str, Any]:
|
|
678
|
+
return rpc("pane.list", {"workspace_id": workspace})
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _panes_from(payload: dict[str, Any]) -> list[PaneGeometry]:
|
|
682
|
+
return [_pane_geometry(entry) for entry in payload.get("panes", [])]
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _container_width_points(payload: dict[str, Any]) -> float:
|
|
686
|
+
"""The workspace's own width, which every share here is taken of.
|
|
687
|
+
|
|
688
|
+
Reported once per `pane.list` reply rather than per pane, because it is the
|
|
689
|
+
frame the panes are laid out inside — summing the panes would instead give
|
|
690
|
+
whatever they currently happen to occupy.
|
|
691
|
+
"""
|
|
692
|
+
frame = payload.get("container_frame") or {}
|
|
693
|
+
return float(frame.get("width") or 0)
|
|
694
|
+
|
|
695
|
+
|
|
612
696
|
def _pane_geometry(entry: dict[str, Any]) -> PaneGeometry:
|
|
613
697
|
frame = entry.get("pixel_frame") or {}
|
|
614
698
|
return PaneGeometry(
|
|
@@ -619,6 +703,7 @@ def _pane_geometry(entry: dict[str, Any]) -> PaneGeometry:
|
|
|
619
703
|
x=int(frame.get("x", 0)),
|
|
620
704
|
y=int(frame.get("y", 0)),
|
|
621
705
|
cell_width_points=int(entry.get("cell_width_points", 0)),
|
|
706
|
+
width_points=float(frame.get("width") or 0),
|
|
622
707
|
ref=str(entry.get("ref", "")),
|
|
623
708
|
selected_surface_id=str(entry.get("selected_surface_id", "")),
|
|
624
709
|
)
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
-
|
|
5
|
+
import re
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
6
8
|
from pathlib import Path
|
|
7
|
-
from typing import Any, Mapping
|
|
9
|
+
from typing import Any, Iterator, Mapping
|
|
10
|
+
|
|
11
|
+
from okstra_ctl.run_context import dir_flock
|
|
8
12
|
|
|
9
13
|
|
|
10
14
|
REQUIRED_FIELDS = (
|
|
@@ -72,9 +76,48 @@ class LeadEvent:
|
|
|
72
76
|
|
|
73
77
|
def append_lead_event(path: Path, event: LeadEvent) -> None:
|
|
74
78
|
"""Append one lead event as compact JSONL, creating parent directories."""
|
|
79
|
+
with _event_log_lock(path):
|
|
80
|
+
_append_unlocked(path, event)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@contextmanager
|
|
84
|
+
def _event_log_lock(path: Path) -> Iterator[None]:
|
|
85
|
+
with dir_flock(path.parent, f".{path.name}.lock"):
|
|
86
|
+
yield
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _append_unlocked(path: Path, event: LeadEvent) -> None:
|
|
75
90
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
-
with path.open("a", encoding="utf-8") as
|
|
77
|
-
|
|
91
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
92
|
+
handle.write(_event_json(event) + "\n")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _next_activity_id(events: list[LeadEvent]) -> str:
|
|
96
|
+
numbers = [
|
|
97
|
+
int(match.group(1))
|
|
98
|
+
for event in events
|
|
99
|
+
if event.event_type == "activity"
|
|
100
|
+
for match in [
|
|
101
|
+
re.fullmatch(
|
|
102
|
+
r"A-(\d{3,})", str(event.details.get("activityId", ""))
|
|
103
|
+
)
|
|
104
|
+
]
|
|
105
|
+
if match is not None
|
|
106
|
+
]
|
|
107
|
+
return f"A-{max(numbers, default=0) + 1:03d}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def append_activity_event(path: Path, event: LeadEvent) -> LeadEvent:
|
|
111
|
+
"""Assign the next activity ID and append the event under one lock."""
|
|
112
|
+
if event.event_type != "activity":
|
|
113
|
+
raise ValueError("activity append requires eventType=activity")
|
|
114
|
+
with _event_log_lock(path):
|
|
115
|
+
details = dict(event.details)
|
|
116
|
+
details["activityVersion"] = 1
|
|
117
|
+
details["activityId"] = _next_activity_id(read_lead_events(path))
|
|
118
|
+
stored = replace(event, details=details)
|
|
119
|
+
_append_unlocked(path, stored)
|
|
120
|
+
return stored
|
|
78
121
|
|
|
79
122
|
|
|
80
123
|
def read_lead_events(path: Path) -> list[LeadEvent]:
|
|
@@ -1501,7 +1501,7 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1501
1501
|
(forces `verificationMode` to "full-reanalysis"), False otherwise
|
|
1502
1502
|
- `planBodyVerification` is implementation-planning specific; the key is
|
|
1503
1503
|
always emitted (dead-letter on other phases) so the schema stays stable.
|
|
1504
|
-
Its `selfFixMaxRounds` default
|
|
1504
|
+
Its `selfFixMaxRounds` default 1 bounds the report-writer self-fix loop
|
|
1505
1505
|
that runs before a planner-fixable defect is promoted to the user.
|
|
1506
1506
|
|
|
1507
1507
|
ctx knobs honoured:
|
|
@@ -1557,7 +1557,7 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1557
1557
|
"planBodyVerification": {
|
|
1558
1558
|
"enabled": plan_verify_enabled,
|
|
1559
1559
|
"maxRounds": 1,
|
|
1560
|
-
"selfFixMaxRounds":
|
|
1560
|
+
"selfFixMaxRounds": 1,
|
|
1561
1561
|
"gating": True,
|
|
1562
1562
|
},
|
|
1563
1563
|
}
|
|
@@ -1565,8 +1565,9 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1565
1565
|
|
|
1566
1566
|
def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
1567
1567
|
run_manifest_file = Path(run_manifest_path)
|
|
1568
|
+
run_manifest_exists = run_manifest_file.is_file()
|
|
1568
1569
|
existing_run_manifest = {}
|
|
1569
|
-
if
|
|
1570
|
+
if run_manifest_exists:
|
|
1570
1571
|
try:
|
|
1571
1572
|
loaded_run_manifest = json.loads(
|
|
1572
1573
|
run_manifest_file.read_text(encoding="utf-8")
|
|
@@ -1767,6 +1768,13 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1767
1768
|
payload["analysisScopeConfirmation"] = scope_confirmation
|
|
1768
1769
|
if ctx.get("FIX_CYCLE_ID"):
|
|
1769
1770
|
payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
|
|
1771
|
+
if ctx.get("TASK_TYPE") == "implementation-planning":
|
|
1772
|
+
if not run_manifest_exists:
|
|
1773
|
+
payload["activityContractVersion"] = 1
|
|
1774
|
+
elif "activityContractVersion" in existing_run_manifest:
|
|
1775
|
+
payload["activityContractVersion"] = existing_run_manifest[
|
|
1776
|
+
"activityContractVersion"
|
|
1777
|
+
]
|
|
1770
1778
|
payload["reportContracts"] = (
|
|
1771
1779
|
["implementation-design-prep-v1"]
|
|
1772
1780
|
if ctx.get("TASK_TYPE") == "implementation-planning"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""Phase 7 report post-processing — the single reference point.
|
|
2
2
|
|
|
3
3
|
Phase 7 turns a Phase 6 final-report data.json into shippable artifacts through
|
|
4
|
-
|
|
5
|
-
rendering, follow-up task spawning, and run validation.
|
|
6
|
-
load-bearing — rendering before substitution ships `--` token
|
|
7
|
-
validating before rendering trips the report-views contract.
|
|
4
|
+
six ordered steps: activity projection, English-SSOT verification, usage
|
|
5
|
+
substitution, html view rendering, follow-up task spawning, and run validation.
|
|
6
|
+
The order is load-bearing — rendering before substitution ships `--` token
|
|
7
|
+
cells, and validating before rendering trips the report-views contract.
|
|
8
8
|
|
|
9
9
|
The translation sidecar is NOT one of these steps. `render-views` overlays it,
|
|
10
10
|
so a non-English run dispatches the translator before this sequence starts —
|
|
@@ -25,11 +25,13 @@ from dataclasses import dataclass
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
from typing import Any, Callable, Mapping, Sequence
|
|
27
27
|
|
|
28
|
+
from .agent_activity import ActivityProjectionError, project_agent_activity
|
|
29
|
+
from .dispatch_state import DispatchError, link_agent_dispatch_result
|
|
28
30
|
from .final_report_paths import final_report_data_path, final_report_markdown_path
|
|
29
31
|
from .paths import task_dir, task_manifest_file
|
|
30
|
-
from .dispatch_state import DispatchError, link_agent_dispatch_result
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
STEP_PROJECT_ACTIVITY = "project-activity"
|
|
33
35
|
STEP_CHECK_SOURCE = "check-source"
|
|
34
36
|
STEP_TOKEN_USAGE = "token-usage"
|
|
35
37
|
STEP_RENDER_VIEWS = "render-views"
|
|
@@ -37,6 +39,7 @@ STEP_SPAWN_FOLLOWUPS = "spawn-followups"
|
|
|
37
39
|
STEP_VALIDATE_RUN = "validate-run"
|
|
38
40
|
|
|
39
41
|
STEP_ORDER = (
|
|
42
|
+
STEP_PROJECT_ACTIVITY,
|
|
40
43
|
# First, because everything after it derives from the data.json: rendering
|
|
41
44
|
# a Korean SSOT into English chrome, spawning follow-ups from it, and
|
|
42
45
|
# validating it all succeed on a record the next phase cannot read.
|
|
@@ -235,6 +238,16 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
|
|
|
235
238
|
"""Assemble the ordered Phase 7 argv list. Order is contractual."""
|
|
236
239
|
markdown_path = ctx.markdown_path
|
|
237
240
|
return [
|
|
241
|
+
(
|
|
242
|
+
STEP_PROJECT_ACTIVITY,
|
|
243
|
+
[
|
|
244
|
+
"<in-process>",
|
|
245
|
+
"agent-activity",
|
|
246
|
+
"project",
|
|
247
|
+
str(ctx.manifest_path),
|
|
248
|
+
str(ctx.data_path),
|
|
249
|
+
],
|
|
250
|
+
),
|
|
238
251
|
(
|
|
239
252
|
STEP_CHECK_SOURCE,
|
|
240
253
|
[
|
|
@@ -349,8 +362,8 @@ def run_finalize(
|
|
|
349
362
|
|
|
350
363
|
``only`` restricts the run to the named steps, preserving contractual
|
|
351
364
|
order. The last step (`validate-run`) is the one that usually fails, and
|
|
352
|
-
re-running the whole sequence to retry it repeats
|
|
353
|
-
at full token and wall-clock cost.
|
|
365
|
+
re-running the whole sequence to retry it repeats every preceding idempotent
|
|
366
|
+
step at full token and wall-clock cost.
|
|
354
367
|
"""
|
|
355
368
|
steps: list[dict[str, Any]] = []
|
|
356
369
|
try:
|
|
@@ -372,6 +385,29 @@ def run_finalize(
|
|
|
372
385
|
for name, command in commands:
|
|
373
386
|
if before_step is not None:
|
|
374
387
|
before_step(name)
|
|
388
|
+
if name == STEP_PROJECT_ACTIVITY:
|
|
389
|
+
try:
|
|
390
|
+
rows = project_agent_activity(
|
|
391
|
+
ctx.project_root,
|
|
392
|
+
ctx.manifest_path,
|
|
393
|
+
ctx.data_path,
|
|
394
|
+
)
|
|
395
|
+
except ActivityProjectionError as exc:
|
|
396
|
+
result = subprocess.CompletedProcess(
|
|
397
|
+
command,
|
|
398
|
+
1,
|
|
399
|
+
"",
|
|
400
|
+
str(exc),
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
result = subprocess.CompletedProcess(
|
|
404
|
+
command,
|
|
405
|
+
0,
|
|
406
|
+
json.dumps({"count": len(rows)}),
|
|
407
|
+
"",
|
|
408
|
+
)
|
|
409
|
+
else:
|
|
410
|
+
result = None
|
|
375
411
|
if name == STEP_VALIDATE_RUN:
|
|
376
412
|
try:
|
|
377
413
|
_link_lead_result_for_validation(ctx)
|
|
@@ -381,12 +417,13 @@ def run_finalize(
|
|
|
381
417
|
"reason": f"lead result linkage failed: {exc}",
|
|
382
418
|
"steps": steps,
|
|
383
419
|
}
|
|
384
|
-
result
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
420
|
+
if result is None:
|
|
421
|
+
result = subprocess.run(
|
|
422
|
+
command,
|
|
423
|
+
cwd=ctx.project_root,
|
|
424
|
+
text=True,
|
|
425
|
+
capture_output=True,
|
|
426
|
+
)
|
|
390
427
|
steps.append(step_payload(name, command, result))
|
|
391
428
|
if result.returncode != 0:
|
|
392
429
|
return {
|
|
@@ -489,7 +526,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
489
526
|
help=(
|
|
490
527
|
"run only these steps (repeatable, contractual order preserved). "
|
|
491
528
|
"Use `--only validate-run` to retry the step that usually fails "
|
|
492
|
-
"without repeating the
|
|
529
|
+
"without repeating the preceding idempotent steps."
|
|
493
530
|
),
|
|
494
531
|
)
|
|
495
532
|
return parser
|
|
@@ -85,6 +85,7 @@ def _own_section_ids(data: dict, omitted_fields: tuple[str, ...] = ()) -> set[st
|
|
|
85
85
|
|
|
86
86
|
found: set[str] = set()
|
|
87
87
|
_collect_ids(data.get("clarificationItems", []), found)
|
|
88
|
+
_collect_ids(data.get("agentActivity", []), found)
|
|
88
89
|
property_name = TASK_TYPE_DATA_PROPERTY.get(data.get("header", {}).get("taskType", ""))
|
|
89
90
|
if property_name:
|
|
90
91
|
block = data.get(property_name, {})
|
|
@@ -120,9 +121,10 @@ def evidence_index(data: dict) -> dict[str, object]:
|
|
|
120
121
|
|
|
121
122
|
def _collect_ids(value: object, found: set[str]) -> None:
|
|
122
123
|
if isinstance(value, dict):
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
for key in ("id", "activityId"):
|
|
125
|
+
row_id = value.get(key)
|
|
126
|
+
if isinstance(row_id, str) and row_id:
|
|
127
|
+
found.add(row_id)
|
|
126
128
|
for nested in value.values():
|
|
127
129
|
_collect_ids(nested, found)
|
|
128
130
|
elif isinstance(value, list):
|
|
@@ -91,6 +91,16 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
|
|
|
91
91
|
planning = data["implementationPlanning"]
|
|
92
92
|
figure = _stage_figure(planning)
|
|
93
93
|
approval = plan_approval_state(data)
|
|
94
|
+
activities = tuple(
|
|
95
|
+
row for row in data.get("agentActivity", []) if isinstance(row, dict)
|
|
96
|
+
)
|
|
97
|
+
decision_cards = tuple(
|
|
98
|
+
row
|
|
99
|
+
for row in data.get("clarificationItems", [])
|
|
100
|
+
if isinstance(row, dict)
|
|
101
|
+
and row.get("blocks") == "approval"
|
|
102
|
+
and isinstance(row.get("approvalContext"), dict)
|
|
103
|
+
)
|
|
94
104
|
context = {
|
|
95
105
|
"humanSummary": data["humanSummary"],
|
|
96
106
|
"planning": planning,
|
|
@@ -100,9 +110,15 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
|
|
|
100
110
|
"openDecisions": [
|
|
101
111
|
row
|
|
102
112
|
for row in data.get("clarificationItems", [])
|
|
103
|
-
if row
|
|
113
|
+
if isinstance(row, dict)
|
|
114
|
+
and row.get("blocks") == "approval"
|
|
104
115
|
and row.get("status") in {"open", "answered"}
|
|
105
116
|
],
|
|
117
|
+
"agentActivities": activities,
|
|
118
|
+
"activityById": {
|
|
119
|
+
row["activityId"]: row for row in activities if row.get("activityId")
|
|
120
|
+
},
|
|
121
|
+
"decisionCards": decision_cards,
|
|
106
122
|
"evidenceIndex": evidence_index(data),
|
|
107
123
|
}
|
|
108
124
|
return HumanReportView(
|