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
|
@@ -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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
|
408
|
-
user's
|
|
409
|
-
|
|
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
|
|
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` 이 `
|
|
634
|
-
다시 물어달라" 이지 답이 아니므로
|
|
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
|
|
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` 이 답을 병합하면서 상태를
|
|
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
|
-
|
|
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
|
-
|
|
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] =
|
|
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 를
|
|
845
|
+
Status 를 answered 로 바꾼 §1 본문을 돌려준다.
|
|
829
846
|
|
|
830
847
|
답의 정본 위치를 §1 표 안으로 옮긴다 — 표만 읽는 승인 게이트·프롬프트
|
|
831
848
|
빌더·검증 워커가 모두 답을 보게 하려는 것. 사이드카는 §1 표 밖 별도 섹션에만
|