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.
Files changed (47) hide show
  1. package/docs/architecture.md +13 -0
  2. package/docs/cli.md +4 -2
  3. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  4. package/docs/project-structure-overview.md +3 -1
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/launch.template.md +4 -0
  8. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  9. package/runtime/prompts/lead/okstra-lead-contract.md +36 -12
  10. package/runtime/prompts/lead/plan-body-verification.md +22 -11
  11. package/runtime/prompts/lead/report-writer.md +11 -10
  12. package/runtime/prompts/lead/team-contract.md +2 -0
  13. package/runtime/prompts/profiles/_clarification-recommendation.md +3 -1
  14. package/runtime/prompts/profiles/_common-contract.md +2 -1
  15. package/runtime/prompts/profiles/implementation-planning.md +8 -1
  16. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
  17. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
  18. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  19. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  20. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  21. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
  22. package/runtime/python/okstra_ctl/agent_activity.py +306 -0
  23. package/runtime/python/okstra_ctl/clarification_items.py +37 -20
  24. package/runtime/python/okstra_ctl/cmux.py +144 -59
  25. package/runtime/python/okstra_ctl/lead_events.py +47 -4
  26. package/runtime/python/okstra_ctl/render.py +11 -3
  27. package/runtime/python/okstra_ctl/report_finalize.py +51 -14
  28. package/runtime/python/okstra_ctl/report_html/common.py +5 -3
  29. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +17 -1
  30. package/runtime/python/okstra_ctl/report_translation.py +14 -0
  31. package/runtime/python/okstra_ctl/worker_audit_ledger.py +150 -0
  32. package/runtime/schemas/final-report-v2.0.schema.json +189 -0
  33. package/runtime/skills/okstra-user-response/SKILL.md +2 -2
  34. package/runtime/templates/reports/final-report-v2.template.md +8 -0
  35. package/runtime/templates/reports/html/assets/base.css +7 -0
  36. package/runtime/templates/reports/html/i18n/en.json +6 -1
  37. package/runtime/templates/reports/html/i18n/ko.json +6 -1
  38. package/runtime/templates/reports/html/macros/forms.html +21 -2
  39. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +25 -0
  40. package/runtime/templates/reports/i18n/en.json +4 -0
  41. package/runtime/templates/reports/report.js +26 -17
  42. package/runtime/templates/reports/user-response.template.md +3 -1
  43. package/runtime/templates/worker-prompt-preamble.md +8 -0
  44. package/runtime/validators/validate-run.py +989 -29
  45. package/runtime/validators/validate_session_conformance.py +523 -35
  46. package/src/cli-registry.mjs +7 -0
  47. package/src/commands/report/agent-activity.mjs +21 -0
@@ -177,7 +177,14 @@
177
177
  ```
178
178
 
179
179
  An `AGREE` note records the counterexample considered and its exclusion reason. If the judgement needs unavailable external material, record `verification-error`, not `DISAGREE`. **Enforced:** `validators/validate-run.py` `_validate_plan_item_extraction_completeness` compares the exact deterministic set, independently rejecting missing, unexpected, and duplicate plan-item IDs, including `P-Prep-*`.
180
- - **§5.5.9 Plan Body Verification (BLOCKING).** After report-writer finishes the draft, the lead MUST run a worker peer-review round on the consolidated plan body (Option Candidates / Trade-off Matrix / Recommended Option / Stage Map and per-stage sections / Dependency / Validation Checklist / Rollback / Requirement Coverage) and populate `### 5.5.9 Plan Body Verification` in the final report. The round protocol, plan-item ID scheme (`P-Opt-*` / `P-Step-*` / `P-Dep-*` / `P-Val-*` / `P-Rb-*`), verdict semantics, gate-result classification, and dissent log format are defined in `prompts/lead/plan-body-verification.md`. The four gate-result values are `passed`, `passed-with-dissent`, `blocked-by-disagreement`, `aborted-non-result`. When the gate would have been `blocked-by-disagreement` or `aborted-non-result`, the lead MUST NOT silently flip it to one of the passing values to "unblock" the run — that is a contract violation. **Enforced:** `validators/validate-run.py` `_validate_plan_body_gate_recompute` re-derives the gate from `planItems[].verdicts` and fails when the declared `gateResult` claims a healthier outcome than the recorded votes support; `_validate_plan_item_extraction_completeness` fails when any plan-body deliverable category is under-extracted into `planItems`, so a dropped item can no longer dodge the gate. When `convergence.adversarial=true` (the default for this phase), this round uses the adversarial posture — verifiers confirm cited paths/commands and the burden of proof is on the plan — but the gate threshold stays `majority-disagree` (see that skill's §"Adversarial plan-body posture"). Among the majority-disagree items, those that are majority-`planner-fixable` go through report-writer self-fix rounds (`prompts/lead/plan-body-verification.md` "Self-fix round"). A `planner-fixable` item that survives the budget is **not** promoted to the user it becomes a Working Assumption in `## 5. Missing Information and Risks` and folds into `passed-with-dissent`, because a defect the planner could have fixed is not a user decision. Only majority-`needs-user-input` items, and correctness-critical defects (`DISAGREE` kind `a`, or `f` on `P-Req-*`) regardless of fixability, become `Blocks=approval` clarifications. Rollback ordering (`d`) is advisory and never blocks approval — a rollback is executed by a human. `validators/validate-run.py` `_validate_self_fix_before_clarification` fails a planner-fixable majority item promoted without a self-fix as `contract-violated`.
180
+ - **§5.5.9 Plan Body Verification (BLOCKING).** After report-writer finishes the draft, the lead MUST run a worker peer-review round on the consolidated plan body (Option Candidates / Trade-off Matrix / Recommended Option / Stage Map and per-stage sections / Dependency / Validation Checklist / Rollback / Requirement Coverage) and populate `### 5.5.9 Plan Body Verification` in the final report. The round protocol, plan-item ID scheme (`P-Opt-*` / `P-Step-*` / `P-Dep-*` / `P-Val-*` / `P-Rb-*`), verdict semantics, gate-result classification, and dissent log format are defined in `prompts/lead/plan-body-verification.md`. The fixed order is initial verification → one planner self-fix → targeted re-verification → user gate. The initial verification is round 1, the targeted re-verification is round 2, and a second automatic self-fix is a contract violation. The four gate-result values are `passed`, `passed-with-dissent`, `blocked-by-disagreement`, `aborted-non-result`. When the gate would have been `blocked-by-disagreement` or `aborted-non-result`, the lead MUST NOT silently flip it to one of the passing values to "unblock" the run — that is a contract violation. **Enforced:** `validators/validate-run.py` `_validate_plan_body_gate_recompute` re-derives the gate from `planItems[].verdicts` and fails when the declared `gateResult` claims a healthier outcome than the recorded votes support; `_validate_plan_item_extraction_completeness` fails when any plan-body deliverable category is under-extracted into `planItems`, so a dropped item can no longer dodge the gate. When `convergence.adversarial=true` (the default for this phase), this round uses the adversarial posture — verifiers confirm cited paths/commands and the burden of proof is on the plan — but the gate threshold stays `majority-disagree` (see that skill's §"Adversarial plan-body posture"). Among the majority-disagree items, those that are majority-`planner-fixable` receive one report-writer self-fix (`prompts/lead/plan-body-verification.md` "Self-fix round"). A surviving item is classified as `user-decision`, `noncritical-dissent`, or `correctness-critical`, and every classification remains a `Blocks=approval` clarification until its own release condition passes. Self-fix exhaustion alone never accepts `noncritical-dissent`; only an explicit, activity-backed `accept-risk` lowers it to `passed-with-dissent`. `correctness-critical` never permits `accept-risk` and resolves only after targeted re-verification records `AGREE` or an acceptable `SUPPLEMENT` and independent Requirement Coverage blockers are gone. Rollback ordering (`d`) is advisory and never blocks approval — a rollback is executed by a human. A user-directed correction does not consume the automatic self-fix limit, and a verification failure after that correction does not restart the automatic loop. `validators/validate-run.py` `_validate_self_fix_before_clarification`, `_validate_approval_context`, and `_resolved_noncritical_dissent_ids` enforce these rules.
181
+ - **Approval decision state.** Every activity-contract-v1 approval row carries `approvalContext` with its classification, linked plan item IDs, `user-decision-required` activity IDs, release condition, and classification-appropriate recommended disposition. Every structured option carries its gate `disposition`; `/okstra-user-response` copies that exact value into the sidecar rather than replacing it with `answer`. A resolved row also carries the user's non-empty raw text and `user-decision-evaluated` check references.
182
+ - `open → answered` when the raw user response is recorded
183
+ - `answered → resolved` only after the selected disposition is applied and its checks pass
184
+ - `answered → open` when application or checking fails
185
+ - `open → obsolete` only when a plan change removes the question
186
+ `open` and `answered` block approval; only `resolved` and `obsolete` are non-blocking. **Enforced:** `validators/validate-run.py` `_validate_approval_context` plus run-prep `scripts/okstra_ctl/run.py` `_validate_approved_plan`.
187
+ - **Terminal approval evidence.** A terminal row preserves a pre-correction dissent classification only when the plan-body state sidecar's superseded votes prove it; the validator does not trust `approvalContext.classification` by itself. Every cited `user-decision-required` / `user-decision-evaluated` activity cites exactly the row's `C-NNN` and exactly the linked `approvalContext.planItemIds` set. A resolved correctness-critical row also binds `resolution.checkRefs` to a later successful evaluation, its zero-exit commands and plan-body state result path, one explicit `plan-body-verification:round-N` token, and matching successful state/report verdicts. That human-response round does not increment `selfFixRoundsApplied`; the automatic cap remains one rewrite. After correcting an independent coverage-only blocker, retain its `C-NNN` in the non-blocking Requirement Coverage row's `decisionRefs` and the matching state-sidecar plan item's `clarificationId`; that item must have no historical blocking dissent and must participate in the historical `coverage-gap` round. A run-wide `coverage-gap` without the item-level `C-NNN` link is insufficient. `obsolete` is invalid while a current linked question, disagreement, or coverage blocker remains active, including a current `user-decision`. **Enforced:** `validators/validate-run.py` `_read_approval_history`, `_activity_matches_approval_context`, `_validate_correctness_resolution`, `_historical_coverage_clarification_ids`, and `_validate_approval_context`.
181
188
  - **Decision-record evaluation (sole owner)**: this phase is the **single owner** of decision-record evaluation in the okstra lifecycle. The brief never evaluates or drafts decision records — it only forwards `adr-candidate:*` signals. Every `adr-candidate:*` entry inherited from the brief's `Open Questions` is a mandatory evaluation target. In addition, evaluate every decision the recommended option introduces against the three criteria:
182
189
  1. **Hard to reverse** — would changing the decision later cost meaningfully more than deciding now?
183
190
  2. **Surprising without context** — would a future reader, seeing only the code, wonder "why was it built this way?"?
@@ -81,7 +81,7 @@ Render every numbered item as its option label followed by its description verba
81
81
  | `await_workers` | Await native host workers through the host primitive and CLI workers through their status sidecars, then verify terminal state and Result Paths. |
82
82
  | `redispatch_worker` | Materialize and verify a fresh invocation, then start a fresh native worker or deterministic `worker-dispatch` attempt according to the persisted runner. |
83
83
  | `shutdown_workers` | Perform host or process cleanup only for resources owned by this run. |
84
- | `record_lead_event` | Append the required structured event to the manifest-provided `leadEventsPath`; emit the matching user-facing `PROGRESS:` line. |
84
+ | `record_lead_event` | Append progress and activity records to the manifest-provided `leadEventsPath`. Emit the matching `PROGRESS:` line and, when an activity record is required, the immediately following `ACTIVITY:` line from the same structured fields. |
85
85
  | `collect_usage` | Collect host- or artifact-backed usage through the existing Okstra token-usage path; do not substitute another runtime's session log. |
86
86
 
87
87
  ## Antigravity dispatch details
@@ -140,7 +140,7 @@ For a `host-text` mapping, render each numbered item as its option label followe
140
140
  | `await_workers` | Arm one background shell poll for the pending Result Paths; the spawn acknowledgement is not completion. |
141
141
  | `redispatch_worker` | Materialize and verify a fresh invocation, then use a fresh native `Agent(...)` session or `okstra worker-dispatch` attempt according to the persisted runner. |
142
142
  | `shutdown_workers` | For each confirmed-complete worker selected for cleanup, send `SendMessage(to: <name>, message: { type: "shutdown_request" })` to idle the roster member **and** call `TaskStop(task_id: "<name>")` to stop its background task. Both are required; neither subsumes the other. |
143
- | `record_lead_event` | Emit the required `PROGRESS:` line as assistant text and persist core-required state/artifact updates. |
143
+ | `record_lead_event` | Append progress and activity records to the manifest-provided `leadEventsPath`, including activity-contract-v1 records. Emit the matching `PROGRESS:` line and, when an activity record is required, the immediately following `ACTIVITY:` line from the same structured fields. |
144
144
  | `collect_usage` | Run `okstra token-usage` against the team-state; it reads the run-scoped `~/.claude/projects` session JSONL evidence. |
145
145
 
146
146
  ## Dispatch variants
@@ -81,7 +81,7 @@ Render every numbered item as its option label followed by its description verba
81
81
  | `await_workers` | Await native host workers through the host primitive and CLI workers through synchronous dispatch, then verify team-state terminal records and Result Paths for both. |
82
82
  | `redispatch_worker` | Materialize and verify a fresh invocation, then start a fresh native worker or `okstra worker-dispatch` attempt according to the persisted runner. |
83
83
  | `shutdown_workers` | Perform process cleanup when a wrapper remains live; otherwise this operation is a no-op recorded in state. |
84
- | `record_lead_event` | Append the required structured event to the manifest-provided `leadEventsPath`; emit the matching user-facing `PROGRESS:` line. |
84
+ | `record_lead_event` | Append progress and activity records to the manifest-provided `leadEventsPath`. Emit the matching `PROGRESS:` line and, when an activity record is required, the immediately following `ACTIVITY:` line from the same structured fields. |
85
85
  | `collect_usage` | Collect artifact/rollout-backed usage through the existing Okstra token-usage path; never read Claude session JSONL as a substitute. |
86
86
 
87
87
  ## Codex dispatch details
@@ -81,7 +81,7 @@ Render every numbered item as its option label followed by its description verba
81
81
  | `await_workers` | Run `okstra team await --project-root <root> --run-manifest <path>` through the host's asynchronous shell facility. |
82
82
  | `redispatch_worker` | Create the core-specified fresh jobs file and dispatch it with a new `dispatchKind`; never reuse a live worker conversation. |
83
83
  | `shutdown_workers` | Run `okstra team teardown --project-root <root> --run-manifest <path>` only after the user-approved cleanup gate. |
84
- | `record_lead_event` | Append the required structured event to the manifest-provided `leadEventsPath`; emit the matching user-facing `PROGRESS:` line. |
84
+ | `record_lead_event` | Append progress and activity records to the manifest-provided `leadEventsPath`. Emit the matching `PROGRESS:` line and, when an activity record is required, the immediately following `ACTIVITY:` line from the same structured fields. |
85
85
  | `collect_usage` | Collect artifact/CLI-log-backed usage through the existing Okstra token-usage path; never substitute another runtime's session log. |
86
86
 
87
87
  ## External dispatch details
@@ -80,7 +80,7 @@ Render every numbered item as its option label followed by its description verba
80
80
  | `await_workers` | Await through the selected common dispatch backend, then verify terminal state and Result Paths. |
81
81
  | `redispatch_worker` | Start a fresh attempt from the persisted assignment and record the supplied dispatch kind. |
82
82
  | `shutdown_workers` | Clean up only host or process resources owned by this run. |
83
- | `record_lead_event` | Append the required structured event to `leadEventsPath` and emit the matching progress line. |
83
+ | `record_lead_event` | Append progress and activity records to the manifest-provided `leadEventsPath`. Emit the matching `PROGRESS:` line and, when an activity record is required, the immediately following `ACTIVITY:` line from the same structured fields. |
84
84
  | `collect_usage` | Return explicit unavailable lead usage until Grok registers a session transcript or CLI usage artifact contract. |
85
85
 
86
86
  ## Completion, cleanup, and resume
@@ -80,7 +80,7 @@ Render every numbered item as its option label followed by its description verba
80
80
  | `await_workers` | Await through the selected common dispatch backend, then verify terminal state and Result Paths. |
81
81
  | `redispatch_worker` | Start a fresh attempt from the persisted assignment and record the supplied dispatch kind. |
82
82
  | `shutdown_workers` | Clean up only host or process resources owned by this run. |
83
- | `record_lead_event` | Append the required structured event to `leadEventsPath` and emit the matching progress line. |
83
+ | `record_lead_event` | Append progress and activity records to the manifest-provided `leadEventsPath`. Emit the matching `PROGRESS:` line and, when an activity record is required, the immediately following `ACTIVITY:` line from the same structured fields. |
84
84
  | `collect_usage` | Return explicit unavailable lead usage until Kimi registers a session transcript or CLI usage artifact contract. |
85
85
 
86
86
  ## Completion, cleanup, and resume
@@ -0,0 +1,306 @@
1
+ """Record lead activity and project it into final-report data."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+ import tempfile
10
+ from collections.abc import Mapping, Sequence
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from okstra_ctl.lead_events import (
16
+ LeadEvent,
17
+ append_activity_event,
18
+ read_lead_events,
19
+ )
20
+ from okstra_ctl.worker_audit_ledger import (
21
+ EvidenceCommand,
22
+ parse_evidence_commands,
23
+ read_evidence_commands,
24
+ )
25
+
26
+
27
+ ACTIVITY_KINDS = frozenset({
28
+ "worker-dispatched",
29
+ "worker-completed",
30
+ "verification-round-completed",
31
+ "self-fix-applied",
32
+ "user-decision-required",
33
+ "user-decision-evaluated",
34
+ })
35
+ ACTIVITY_OUTCOMES = frozenset({
36
+ "pending",
37
+ "completed",
38
+ "failed",
39
+ "blocked",
40
+ "resolved",
41
+ })
42
+ ACTIVITY_FIELDS = (
43
+ "activityId",
44
+ "kind",
45
+ "agent",
46
+ "summary",
47
+ "planItemIds",
48
+ "resultPath",
49
+ "commands",
50
+ "evidenceRefs",
51
+ "outcome",
52
+ )
53
+ _ACTIVITY_ID_RE = re.compile(r"^A-(\d{3,})$")
54
+
55
+
56
+ class ActivityProjectionError(ValueError):
57
+ """Raised when activity cannot be recorded or projected safely."""
58
+
59
+
60
+ def _read_json_object(path: Path) -> dict[str, Any]:
61
+ try:
62
+ payload = json.loads(path.read_text(encoding="utf-8"))
63
+ except (OSError, json.JSONDecodeError) as exc:
64
+ raise ActivityProjectionError(f"cannot read JSON object {path}: {exc}") from exc
65
+ if not isinstance(payload, dict):
66
+ raise ActivityProjectionError(f"JSON value must be an object: {path}")
67
+ return payload
68
+
69
+
70
+ def _resolve_project_path(project_root: Path, value: str | Path) -> Path:
71
+ path = Path(value)
72
+ return path if path.is_absolute() else project_root / path
73
+
74
+
75
+ def _require_string(payload: Mapping[str, Any], key: str) -> str:
76
+ value = payload.get(key)
77
+ if not isinstance(value, str) or not value.strip():
78
+ raise ActivityProjectionError(f"required string missing: {key}")
79
+ return value.strip()
80
+
81
+
82
+ def _manifest_run_seq(manifest: Mapping[str, Any]) -> str:
83
+ sequences = manifest.get("runSequencesByCategory")
84
+ if not isinstance(sequences, Mapping):
85
+ raise ActivityProjectionError(
86
+ "run manifest has no runSequencesByCategory object"
87
+ )
88
+ return _require_string(sequences, "manifests")
89
+
90
+
91
+ def _utc_now() -> str:
92
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
93
+
94
+
95
+ def _activity_row(event: LeadEvent) -> dict[str, Any]:
96
+ details = dict(event.details)
97
+ missing = [key for key in ACTIVITY_FIELDS if key not in details]
98
+ if missing:
99
+ raise ActivityProjectionError(
100
+ "activity event is missing " + ", ".join(missing)
101
+ )
102
+ if details["kind"] not in ACTIVITY_KINDS:
103
+ raise ActivityProjectionError(
104
+ f"unsupported activity kind: {details['kind']}"
105
+ )
106
+ if details["outcome"] not in ACTIVITY_OUTCOMES:
107
+ raise ActivityProjectionError(
108
+ f"unsupported activity outcome: {details['outcome']}"
109
+ )
110
+ activity_id = details["activityId"]
111
+ if (
112
+ not isinstance(activity_id, str)
113
+ or _ACTIVITY_ID_RE.fullmatch(activity_id) is None
114
+ ):
115
+ raise ActivityProjectionError(f"invalid activityId: {activity_id}")
116
+ return {key: details[key] for key in ACTIVITY_FIELDS}
117
+
118
+
119
+ def _validate_activity_order(rows: Sequence[Mapping[str, Any]]) -> None:
120
+ ids = [str(row["activityId"]) for row in rows]
121
+ if len(ids) != len(set(ids)):
122
+ raise ActivityProjectionError("activityId values must be unique")
123
+ numbers = [int(_ACTIVITY_ID_RE.fullmatch(value).group(1)) for value in ids]
124
+ if any(current <= previous for previous, current in zip(numbers, numbers[1:])):
125
+ raise ActivityProjectionError(
126
+ "activity events must be in strict activityId order"
127
+ )
128
+
129
+
130
+ def _atomic_write_json(path: Path, payload: Mapping[str, Any]) -> None:
131
+ path.parent.mkdir(parents=True, exist_ok=True)
132
+ temporary_path: Path | None = None
133
+ try:
134
+ with tempfile.NamedTemporaryFile(
135
+ mode="w",
136
+ encoding="utf-8",
137
+ dir=path.parent,
138
+ prefix=f".{path.name}.",
139
+ suffix=".tmp",
140
+ delete=False,
141
+ ) as handle:
142
+ temporary_path = Path(handle.name)
143
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
144
+ handle.write("\n")
145
+ os.replace(temporary_path, path)
146
+ finally:
147
+ if temporary_path is not None and temporary_path.exists():
148
+ temporary_path.unlink()
149
+
150
+
151
+ def record_activity(
152
+ project_root: Path,
153
+ run_manifest_path: Path,
154
+ details: Mapping[str, Any],
155
+ timestamp: str | None = None,
156
+ ) -> LeadEvent:
157
+ """Append one activity event using identity from its run manifest."""
158
+ manifest = _read_json_object(run_manifest_path)
159
+ if manifest.get("activityContractVersion") != 1:
160
+ raise ActivityProjectionError(
161
+ "run manifest does not declare activityContractVersion 1"
162
+ )
163
+ event = LeadEvent(
164
+ event_type="activity",
165
+ lead_runtime=_require_string(manifest, "leadRuntime"),
166
+ task_key=_require_string(manifest, "taskKey"),
167
+ task_type=_require_string(manifest, "taskType"),
168
+ run_seq=_manifest_run_seq(manifest),
169
+ timestamp=timestamp or _utc_now(),
170
+ details=dict(details),
171
+ )
172
+ events_path = _resolve_project_path(
173
+ project_root, _require_string(manifest, "leadEventsPath")
174
+ )
175
+ return append_activity_event(events_path, event)
176
+
177
+
178
+ def project_agent_activity(
179
+ project_root: Path,
180
+ run_manifest_path: Path,
181
+ data_path: Path,
182
+ ) -> tuple[dict[str, Any], ...]:
183
+ """Replace only ``agentActivity`` with this run's canonical events."""
184
+ manifest = _read_json_object(run_manifest_path)
185
+ if manifest.get("activityContractVersion") != 1:
186
+ return ()
187
+ events_path = _resolve_project_path(
188
+ project_root, _require_string(manifest, "leadEventsPath")
189
+ )
190
+ run_seq = _manifest_run_seq(manifest)
191
+ task_key = _require_string(manifest, "taskKey")
192
+ task_type = _require_string(manifest, "taskType")
193
+ events = (
194
+ event
195
+ for event in read_lead_events(events_path)
196
+ if event.event_type == "activity"
197
+ and event.task_key == task_key
198
+ and event.task_type == task_type
199
+ and event.run_seq == run_seq
200
+ )
201
+ rows = tuple(_activity_row(event) for event in events)
202
+ _validate_activity_order(rows)
203
+ data = _read_json_object(data_path)
204
+ data["agentActivity"] = list(rows)
205
+ _atomic_write_json(data_path, data)
206
+ return rows
207
+
208
+
209
+ def _parse_command_records(raw_records: Sequence[str]) -> tuple[EvidenceCommand, ...]:
210
+ content = "\n".join(
211
+ f"- Evidence command: {record}" for record in raw_records
212
+ )
213
+ commands, failures = parse_evidence_commands(content)
214
+ if failures:
215
+ raise ActivityProjectionError("; ".join(failures))
216
+ return commands
217
+
218
+
219
+ def _activity_commands(args: argparse.Namespace) -> tuple[EvidenceCommand, ...]:
220
+ commands = list(_parse_command_records(args.command_record))
221
+ if args.audit_sidecar is not None:
222
+ audit_commands, failures = read_evidence_commands(args.audit_sidecar)
223
+ if failures:
224
+ raise ActivityProjectionError("; ".join(failures))
225
+ commands.extend(audit_commands)
226
+ return tuple(commands)
227
+
228
+
229
+ def _conversation_activity_line(details: Mapping[str, Any]) -> str:
230
+ plan_items = ",".join(details["planItemIds"]) or "<none>"
231
+ result_path = str(details["resultPath"] or "<none>")
232
+ summary = json.dumps(details["summary"], ensure_ascii=False)
233
+ return (
234
+ f"ACTIVITY: id={details['activityId']} agent={details['agent']} "
235
+ f"summary={summary} items={plan_items} result={result_path} "
236
+ f"outcome={details['outcome']}"
237
+ )
238
+
239
+
240
+ def _append(args: argparse.Namespace) -> int:
241
+ commands = _activity_commands(args)
242
+ details = {
243
+ "kind": args.kind,
244
+ "agent": args.agent,
245
+ "summary": args.summary,
246
+ "planItemIds": args.plan_item_id,
247
+ "resultPath": args.result_path,
248
+ "commands": [command.to_record() for command in commands],
249
+ "evidenceRefs": args.evidence_ref,
250
+ "outcome": args.outcome,
251
+ }
252
+ event = record_activity(args.project_root, args.run_manifest, details)
253
+ payload = dict(event.details)
254
+ payload["ok"] = True
255
+ payload["activityLine"] = _conversation_activity_line(payload)
256
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
257
+ return 0
258
+
259
+
260
+ def _project(args: argparse.Namespace) -> int:
261
+ rows = project_agent_activity(args.project_root, args.run_manifest, args.data)
262
+ print(json.dumps(
263
+ {"ok": True, "count": len(rows), "agentActivity": rows},
264
+ ensure_ascii=False,
265
+ indent=2,
266
+ ))
267
+ return 0
268
+
269
+
270
+ def _parser() -> argparse.ArgumentParser:
271
+ parser = argparse.ArgumentParser(prog="okstra agent-activity")
272
+ subparsers = parser.add_subparsers(dest="command", required=True)
273
+ append_parser = subparsers.add_parser("append")
274
+ append_parser.add_argument("--project-root", type=Path, required=True)
275
+ append_parser.add_argument("--run-manifest", type=Path, required=True)
276
+ append_parser.add_argument(
277
+ "--kind", choices=sorted(ACTIVITY_KINDS), required=True
278
+ )
279
+ append_parser.add_argument("--agent", required=True)
280
+ append_parser.add_argument("--summary", required=True)
281
+ append_parser.add_argument(
282
+ "--outcome", choices=sorted(ACTIVITY_OUTCOMES), required=True
283
+ )
284
+ append_parser.add_argument("--plan-item-id", action="append", default=[])
285
+ append_parser.add_argument("--evidence-ref", action="append", default=[])
286
+ append_parser.add_argument("--command-record", action="append", default=[])
287
+ append_parser.add_argument("--result-path", default="")
288
+ append_parser.add_argument("--audit-sidecar", type=Path)
289
+ project_parser = subparsers.add_parser("project")
290
+ project_parser.add_argument("--project-root", type=Path, required=True)
291
+ project_parser.add_argument("--run-manifest", type=Path, required=True)
292
+ project_parser.add_argument("--data", type=Path, required=True)
293
+ return parser
294
+
295
+
296
+ def main(argv: list[str] | None = None) -> int:
297
+ args = _parser().parse_args(argv)
298
+ try:
299
+ return _append(args) if args.command == "append" else _project(args)
300
+ except ActivityProjectionError as exc:
301
+ print(f"okstra agent-activity: {exc}", file=sys.stderr)
302
+ return 1
303
+
304
+
305
+ if __name__ == "__main__":
306
+ raise SystemExit(main(sys.argv[1:]))
@@ -372,6 +372,13 @@ APPROVAL_BLOCKS = frozenset({"approval"})
372
372
  # user-response skill must list them — counting only `approval` made a report
373
373
  # whose open items were all `next-phase` look like it had none.
374
374
  USER_INPUT_BLOCKS = frozenset({"approval", "next-phase"})
375
+ ANSWER_DISPOSITIONS = frozenset({
376
+ "answer",
377
+ "select",
378
+ "accept-risk",
379
+ "request-revision",
380
+ "reject",
381
+ })
375
382
 
376
383
 
377
384
  @dataclass(frozen=True)
@@ -391,28 +398,39 @@ class ClarificationScan:
391
398
  def scan_approval_gate(report_path: Path) -> ClarificationScan:
392
399
  """Scan for unresolved ``Blocks=approval`` rows (``Status`` in
393
400
  ``{open, answered}``), refusing to guess whenever the schema drifted."""
394
- return scan_clarification_blockers(report_path, APPROVAL_BLOCKS)
401
+ return scan_clarification_blockers(
402
+ report_path, APPROVAL_BLOCKS, honor_sidecar_answers=False
403
+ )
395
404
 
396
405
 
397
406
  def scan_open_user_input(report_path: Path) -> ClarificationScan:
398
407
  """Scan for every unresolved row that still owes the user an answer
399
408
  (``Blocks`` in ``{approval, next-phase}``)."""
400
- return scan_clarification_blockers(report_path, USER_INPUT_BLOCKS)
409
+ return scan_clarification_blockers(
410
+ report_path, USER_INPUT_BLOCKS, honor_sidecar_answers=True
411
+ )
401
412
 
402
413
 
403
414
  def scan_clarification_blockers(
404
- report_path: Path, blocking_values: frozenset[str]
415
+ report_path: Path,
416
+ blocking_values: frozenset[str],
417
+ *,
418
+ honor_sidecar_answers: bool,
405
419
  ) -> ClarificationScan:
406
420
  """Shared fail-closed clarification walk for both gates above — schema-v2
407
- reads its rows from the data sibling, schema-v1 from the §1 table, and the
408
- user's `user-responses/` sidecar outranks whatever the report says about
409
- those rows."""
421
+ reads its rows from the data sibling and schema-v1 from the §1 table.
422
+ ``honor_sidecar_answers`` controls whether a user's sidecar hides answered
423
+ rows from this scan; approval deliberately passes ``False``."""
410
424
  v2_scan = _scan_v2_blockers(report_path, blocking_values)
411
425
  scan = (
412
426
  v2_scan if v2_scan is not None
413
427
  else scan_section_1_blockers(_read_report_text(report_path), blocking_values)
414
428
  )
415
- return _resolve_blockers_answered_by_user(report_path, scan)
429
+ return (
430
+ _resolve_blockers_answered_by_user(report_path, scan)
431
+ if honor_sidecar_answers
432
+ else scan
433
+ )
416
434
 
417
435
 
418
436
  def _resolve_blockers_answered_by_user(
@@ -422,9 +440,8 @@ def _resolve_blockers_answered_by_user(
422
440
 
423
441
  답의 정본은 사용자의 `user-responses/` 사이드카다. 리포트의 `Status` 는 그
424
442
  run 이 스스로 적어둔 값이고, 답이 사이드카로만 들어오는 경로(HTML 뷰의
425
- `Export user response`, `okstra user-response write`)에서는 갱신되지
426
- 않는다 게이트가 리포트만 보면 사용자가 답을 채운 뒤에도 같은 항목이
427
- 영원히 미해결로 남아 다음 phase 를 막는다.
443
+ `Export user response`, `okstra user-response write`)에서는 갱신되지 않는다.
444
+ 사용자 입력 목록은 사이드카 답변이 있는 항목을 다시 묻지 않는다.
428
445
 
429
446
  fail-closed 는 그대로다: 행 자체를 못 읽은 스캔(`unreadable_reason`)은
430
447
  어떤 id 가 blocker 인지 모르는 상태이므로 사이드카로 덮지 않는다.
@@ -630,10 +647,10 @@ def sidecar_answers(source: Path) -> dict[str, str]:
630
647
  사용자가 답한 항목이 무엇인지 아는 단일 참조점 — carry-in 병합도, 승인
631
648
  게이트도, 스킬의 열린 항목 목록도 전부 이 한 곳을 본다.
632
649
 
633
- `disposition` 이 `answer` 항목만 답으로 센다. `reframe` 은 "다음 run 에서
634
- 다시 물어달라" 이지 답이 아니므로(`skills/okstra-user-response/SKILL.md` 의
635
- "A reframe is not an answer") 답 집합에서 빠져야 게이트가 그 항목을 계속
636
- 미해결로 잡는다.
650
+ `disposition` 이 `ANSWER_DISPOSITIONS` 속하는 항목만 답으로 센다.
651
+ `reframe` 은 "다음 run 에서 다시 물어달라" 이지 답이 아니므로
652
+ (`skills/okstra-user-response/SKILL.md` 의 "A reframe is not an answer")
653
+ 집합에서 빠져야 사용자 입력 목록에 그 항목이 계속 남는다.
637
654
 
638
655
  같은 id 가 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다 — 최신이
639
656
  reframe 이거나 값이 비면 앞선 답을 지운다. 그래야 답을 물렀을 때 그 항목이
@@ -646,7 +663,7 @@ def sidecar_answers(source: Path) -> dict[str, str]:
646
663
  for entry in parse_user_response_entries(
647
664
  sidecar.read_text(encoding="utf-8")
648
665
  ):
649
- if entry.value and entry.disposition == "answer":
666
+ if entry.value and entry.disposition in ANSWER_DISPOSITIONS:
650
667
  answers[entry.response_id] = entry.value
651
668
  else:
652
669
  answers.pop(entry.response_id, None)
@@ -759,7 +776,7 @@ def _v2_section_1_body(entries: list) -> str:
759
776
  """schema-v2 `clarificationItems[]` 를 §1 표 본문으로.
760
777
 
761
778
  메타 셀은 렌더러가 쓰는 모양 그대로다 — `Status:` 는 따옴표 없이 써야
762
- `_reconcile_user_input` 이 답을 병합하면서 상태를 resolved 로 넘길 수 있다."""
779
+ `_reconcile_user_input` 이 답을 병합하면서 상태를 answered 로 넘길 수 있다."""
763
780
  rows = [e for e in entries if isinstance(e, dict) and e.get("id")]
764
781
  if not rows:
765
782
  return f"\n{_SECTION_1_EMPTY_STATE}"
@@ -783,7 +800,7 @@ def _v2_section_1_body(entries: list) -> str:
783
800
  # The final-report renderer writes `Status: open` / `Status: answered` unquoted
784
801
  # in the stacked meta cell; only those two are unresolved. Resolve in place so
785
802
  # the meta cell's other fields (ID, Ticket, Kind, Blocks) are left untouched.
786
- _STATUS_RESOLVE_RE = re.compile(r"(Status:\s*)(?:open|answered)\b", re.IGNORECASE)
803
+ _STATUS_ANSWER_RE = re.compile(r"(Status:\s*)(?:open|answered)\b", re.IGNORECASE)
787
804
 
788
805
 
789
806
  def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
@@ -799,7 +816,7 @@ def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
799
816
 
800
817
  def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
801
818
  """답이 있고 open/answered 인 행이면 `User input` 칸을 그 답으로 채우고 Status 를
802
- resolved 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다.
819
+ answered 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다.
803
820
 
804
821
  칸에 이미 값이 있어도 사용자의 사이드카 답이 이긴다. 그 칸을 채우는 것은
805
822
  run 자신(직전 렌더가 옮겨 적은 값)이고, 사용자가 나중에 답을 바꾸면 둘이
@@ -819,13 +836,13 @@ def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
819
836
  if not 0 <= ui_col < len(raw):
820
837
  return line
821
838
  raw[ui_col] = answers[item.row_id]
822
- raw[0] = _STATUS_RESOLVE_RE.sub(r"\1resolved", raw[0])
839
+ raw[0] = _STATUS_ANSWER_RE.sub(r"\1answered", raw[0])
823
840
  return "| " + " | ".join(to_cell_text(c) for c in raw) + " |"
824
841
 
825
842
 
826
843
  def _reconcile_user_input(section: str, answers: dict[str, str]) -> str:
827
844
  """§1 표에서 사이드카 답이 있는 미해결 행의 `User input` 칸을 답으로 채우고
828
- Status 를 resolved 로 바꾼 §1 본문을 돌려준다.
845
+ Status 를 answered 로 바꾼 §1 본문을 돌려준다.
829
846
 
830
847
  답의 정본 위치를 §1 표 안으로 옮긴다 — 표만 읽는 승인 게이트·프롬프트
831
848
  빌더·검증 워커가 모두 답을 보게 하려는 것. 사이드카는 §1 표 밖 별도 섹션에만