okstra 0.171.0 → 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 (46) 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/lead_events.py +47 -4
  25. package/runtime/python/okstra_ctl/render.py +11 -3
  26. package/runtime/python/okstra_ctl/report_finalize.py +51 -14
  27. package/runtime/python/okstra_ctl/report_html/common.py +5 -3
  28. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +17 -1
  29. package/runtime/python/okstra_ctl/report_translation.py +14 -0
  30. package/runtime/python/okstra_ctl/worker_audit_ledger.py +150 -0
  31. package/runtime/schemas/final-report-v2.0.schema.json +189 -0
  32. package/runtime/skills/okstra-user-response/SKILL.md +2 -2
  33. package/runtime/templates/reports/final-report-v2.template.md +8 -0
  34. package/runtime/templates/reports/html/assets/base.css +7 -0
  35. package/runtime/templates/reports/html/i18n/en.json +6 -1
  36. package/runtime/templates/reports/html/i18n/ko.json +6 -1
  37. package/runtime/templates/reports/html/macros/forms.html +21 -2
  38. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +25 -0
  39. package/runtime/templates/reports/i18n/en.json +4 -0
  40. package/runtime/templates/reports/report.js +26 -17
  41. package/runtime/templates/reports/user-response.template.md +3 -1
  42. package/runtime/templates/worker-prompt-preamble.md +8 -0
  43. package/runtime/validators/validate-run.py +989 -29
  44. package/runtime/validators/validate_session_conformance.py +523 -35
  45. package/src/cli-registry.mjs +7 -0
  46. package/src/commands/report/agent-activity.mjs +21 -0
@@ -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
- five ordered steps: English-SSOT verification, usage substitution, html view
5
- rendering, follow-up task spawning, and run validation. The order is
6
- load-bearing — rendering before substitution ships `--` token cells, and
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 three idempotent steps
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 = subprocess.run(
385
- command,
386
- cwd=ctx.project_root,
387
- text=True,
388
- capture_output=True,
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 three idempotent ones before it."
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
- row_id = value.get("id")
124
- if isinstance(row_id, str) and row_id:
125
- found.add(row_id)
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.get("blocks") == "approval"
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(
@@ -125,8 +125,10 @@ PROSE_KEYS = frozenset({
125
125
  "topic",
126
126
  "trigger",
127
127
  "triggerSignal",
128
+ "unblockCondition",
128
129
  "userChoice",
129
130
  "userInput",
131
+ "userText",
130
132
  "verification",
131
133
  "verificationMethod",
132
134
  "verificationSignal",
@@ -138,6 +140,8 @@ PROSE_KEYS = frozenset({
138
140
  # that must stay verbatim to be evidence at all.
139
141
  STRUCTURAL_KEYS = frozenset({
140
142
  "action", # implementation.py: _FILE_ACTIONS lookup key
143
+ "activityId",
144
+ "activityIds",
141
145
  "adapter",
142
146
  "affectedId",
143
147
  "affectedTarget",
@@ -152,12 +156,15 @@ STRUCTURAL_KEYS = frozenset({
152
156
  "capturedHeadSha",
153
157
  "carriedForwardFromSeq",
154
158
  "carryJson",
159
+ "checkRefs",
155
160
  "clarificationId",
161
+ "classification",
156
162
  "claudeCode",
157
163
  "collectorBranch",
158
164
  "command",
159
165
  "commandOrObservation",
160
166
  "commandOrTest",
167
+ "commands",
161
168
  "commitListQuote",
162
169
  "complexity",
163
170
  "componentId",
@@ -165,14 +172,18 @@ STRUCTURAL_KEYS = frozenset({
165
172
  "conformanceTests",
166
173
  "coveredBy",
167
174
  "createdAt",
175
+ "cwd",
168
176
  "cycle",
169
177
  "dataStore",
170
178
  "date",
171
179
  "dependsOn",
172
180
  "diffSummaryQuote",
173
181
  "direction",
182
+ "disposition",
174
183
  "dispatches",
175
184
  "executorWorktreePath",
185
+ "evidenceRefs",
186
+ "exitCode",
176
187
  "existingPrUrl",
177
188
  "externalSystem",
178
189
  "featureId",
@@ -215,6 +226,7 @@ STRUCTURAL_KEYS = frozenset({
215
226
  "ownerComponentId",
216
227
  "path",
217
228
  "planFile",
229
+ "planItemIds",
218
230
  "planStep",
219
231
  "prepItemId",
220
232
  "project",
@@ -223,11 +235,13 @@ STRUCTURAL_KEYS = frozenset({
223
235
  "rawStat",
224
236
  "readBoundary",
225
237
  "readOnlyCommandLog",
238
+ "recommendedDisposition",
226
239
  "relatedIds",
227
240
  "relation",
228
241
  "replacedInTest",
229
242
  "replacedWith",
230
243
  "reportPath",
244
+ "resultPath",
231
245
  "requestKind",
232
246
  "requestPath",
233
247
  "requestVerbatim", # the user's own words, quoted as evidence
@@ -10,7 +10,9 @@ so the rules live here rather than inside either one — the same split
10
10
  """
11
11
  from __future__ import annotations
12
12
 
13
+ import json
13
14
  import re
15
+ from collections.abc import Mapping, Sequence
14
16
  from dataclasses import dataclass
15
17
  from pathlib import Path
16
18
 
@@ -39,6 +41,29 @@ _EVIDENCE_READ_RE = re.compile(
39
41
  r"^- Evidence read: `?(?P<path>[^`\n]+?)`?\s*$",
40
42
  re.MULTILINE,
41
43
  )
44
+ _EVIDENCE_COMMAND_RE = re.compile(
45
+ r"^- Evidence command:[ \t]*(?P<payload>.*)$", re.MULTILINE
46
+ )
47
+ _EVIDENCE_COMMAND_FIELDS = ("command", "cwd", "exitCode", "outputSummary")
48
+ _EVIDENCE_COMMAND_STRING_FIELDS = ("command", "cwd", "outputSummary")
49
+ _SENSITIVE_KEY_RE = re.compile(
50
+ r"(?i)(authorization|auth|credential|pass(?:wd|word)?|pwd|token|secret|"
51
+ r"api[_-]?key|access[_-]?key)"
52
+ )
53
+ _SENSITIVE_TEXT_RE = re.compile(
54
+ r"(?i)(authorization\s*:\s*\S+|"
55
+ r"\b(?:[A-Za-z_][A-Za-z0-9_-]*)?(?:auth|credential|pass(?:wd|word)?|pwd|"
56
+ r"token|secret|api[_-]?key|access[_-]?key)[A-Za-z0-9_-]*\s*=|"
57
+ r"--[\w-]*(?:auth|credential|pass(?:wd|word)?|pwd|token|secret|"
58
+ r"api[-_]?key|access[-_]?key)[\w-]*(?:[ =])\S+|"
59
+ r"\w+://[^\s:/@]+:[^\s@/]+@)"
60
+ )
61
+ _KNOWN_SECRET_RE = re.compile(
62
+ r"(?:AKIA|ASIA)[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{8,}|"
63
+ r"github_pat_[A-Za-z0-9_]{8,}|xox[abprs]-[A-Za-z0-9-]{8,}|"
64
+ r"\bsk-[A-Za-z0-9_-]{8,}\b|"
65
+ r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"
66
+ )
42
67
  _FILE_LINE_CITATION_RE = re.compile(
43
68
  r"`(?P<path>(?!https?://)[^`\n]+?):(?P<line>\d+(?:-\d+)?)`"
44
69
  )
@@ -85,6 +110,129 @@ def _audit_evidence_read_paths(content: str) -> set[str]:
85
110
  }
86
111
 
87
112
 
113
+ @dataclass(frozen=True)
114
+ class EvidenceCommand:
115
+ command: str
116
+ cwd: str
117
+ exit_code: int
118
+ output_summary: str
119
+
120
+ def to_record(self) -> dict[str, object]:
121
+ return {
122
+ "command": self.command,
123
+ "cwd": self.cwd,
124
+ "exitCode": self.exit_code,
125
+ "outputSummary": self.output_summary,
126
+ }
127
+
128
+
129
+ def _contains_sensitive_material(value: object) -> bool:
130
+ if isinstance(value, Mapping):
131
+ return any(
132
+ (
133
+ _SENSITIVE_KEY_RE.search(str(key)) is not None
134
+ and nested not in (None, "", False)
135
+ )
136
+ or _contains_sensitive_material(nested)
137
+ for key, nested in value.items()
138
+ )
139
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
140
+ return any(_contains_sensitive_material(item) for item in value)
141
+ if not isinstance(value, str):
142
+ return False
143
+ return (
144
+ _SENSITIVE_TEXT_RE.search(value) is not None
145
+ or _KNOWN_SECRET_RE.search(value) is not None
146
+ )
147
+
148
+
149
+ def _evidence_command_contract_failures(
150
+ payload: Mapping[str, object], row_number: int
151
+ ) -> list[str]:
152
+ failures: list[str] = []
153
+ missing = [key for key in _EVIDENCE_COMMAND_FIELDS if key not in payload]
154
+ unexpected = sorted(set(payload) - set(_EVIDENCE_COMMAND_FIELDS))
155
+ if missing:
156
+ failures.append(
157
+ f"Evidence command row {row_number} is missing {', '.join(missing)}"
158
+ )
159
+ if unexpected:
160
+ failures.append(
161
+ f"Evidence command row {row_number} has unexpected "
162
+ f"{', '.join(unexpected)}"
163
+ )
164
+ for field_name in _EVIDENCE_COMMAND_STRING_FIELDS:
165
+ if field_name in payload and not isinstance(payload[field_name], str):
166
+ failures.append(
167
+ f"Evidence command row {row_number} {field_name} must be a string"
168
+ )
169
+ if "exitCode" in payload and type(payload["exitCode"]) is not int:
170
+ failures.append(
171
+ f"Evidence command row {row_number} exitCode must be an integer"
172
+ )
173
+ return failures
174
+
175
+
176
+ def _evidence_command_from_payload(
177
+ payload: Mapping[str, object], sensitive: bool
178
+ ) -> EvidenceCommand:
179
+ if sensitive:
180
+ return EvidenceCommand(
181
+ command="[redacted: potential secret]",
182
+ cwd="[redacted: potential secret]",
183
+ exit_code=payload["exitCode"],
184
+ output_summary="omitted because potential secret material was detected",
185
+ )
186
+ return EvidenceCommand(
187
+ command=payload["command"],
188
+ cwd=payload["cwd"],
189
+ exit_code=payload["exitCode"],
190
+ output_summary=payload["outputSummary"],
191
+ )
192
+
193
+
194
+ def parse_evidence_commands(
195
+ content: str,
196
+ ) -> tuple[tuple[EvidenceCommand, ...], tuple[str, ...]]:
197
+ commands: list[EvidenceCommand] = []
198
+ failures: list[str] = []
199
+ for row_number, match in enumerate(
200
+ _EVIDENCE_COMMAND_RE.finditer(content), start=1
201
+ ):
202
+ try:
203
+ payload = json.loads(match.group("payload"))
204
+ except json.JSONDecodeError:
205
+ failures.append(f"Evidence command row {row_number} is not valid JSON")
206
+ continue
207
+ if not isinstance(payload, Mapping):
208
+ failures.append(
209
+ f"Evidence command row {row_number} must be a JSON object"
210
+ )
211
+ continue
212
+ sensitive = _contains_sensitive_material(payload)
213
+ if sensitive:
214
+ failures.append(
215
+ f"Evidence command row {row_number} contains sensitive material"
216
+ )
217
+ contract_failures = _evidence_command_contract_failures(payload, row_number)
218
+ failures.extend(contract_failures)
219
+ if contract_failures:
220
+ continue
221
+ commands.append(_evidence_command_from_payload(payload, sensitive))
222
+ return tuple(commands), tuple(failures)
223
+
224
+
225
+ def read_evidence_commands(
226
+ audit_path: Path,
227
+ ) -> tuple[tuple[EvidenceCommand, ...], tuple[str, ...]]:
228
+ try:
229
+ return parse_evidence_commands(audit_path.read_text(encoding="utf-8"))
230
+ except OSError as exc:
231
+ return (), (
232
+ f"worker audit sidecar unreadable: {audit_path.name} ({exc})",
233
+ )
234
+
235
+
88
236
  def _worker_prompt_path(
89
237
  run_dir: Path,
90
238
  worker_role: str,
@@ -276,6 +424,8 @@ def check_worker_results_audit(
276
424
  )
277
425
  continue
278
426
 
427
+ _, command_failures = read_evidence_commands(audit_path)
428
+ failures.extend(command_failures)
279
429
  failures.extend(_evidence_read_ledger_failures(
280
430
  run_dir=run_dir,
281
431
  worker_role=worker_role,
@@ -171,6 +171,12 @@
171
171
  }
172
172
  }
173
173
  },
174
+ "agentActivity": {
175
+ "type": "array",
176
+ "items": {
177
+ "$ref": "#/$defs/AgentActivityRow"
178
+ }
179
+ },
174
180
  "verificationScope": {
175
181
  "enum": [
176
182
  "whole-task",
@@ -2587,6 +2593,103 @@
2587
2593
  }
2588
2594
  ],
2589
2595
  "$defs": {
2596
+ "ActivityCommand": {
2597
+ "type": "object",
2598
+ "required": [
2599
+ "command",
2600
+ "cwd",
2601
+ "exitCode",
2602
+ "outputSummary"
2603
+ ],
2604
+ "additionalProperties": false,
2605
+ "properties": {
2606
+ "command": {
2607
+ "type": "string"
2608
+ },
2609
+ "cwd": {
2610
+ "type": "string"
2611
+ },
2612
+ "exitCode": {
2613
+ "type": "integer"
2614
+ },
2615
+ "outputSummary": {
2616
+ "type": "string"
2617
+ }
2618
+ }
2619
+ },
2620
+ "AgentActivityRow": {
2621
+ "type": "object",
2622
+ "required": [
2623
+ "activityId",
2624
+ "kind",
2625
+ "agent",
2626
+ "summary",
2627
+ "planItemIds",
2628
+ "resultPath",
2629
+ "commands",
2630
+ "evidenceRefs",
2631
+ "outcome"
2632
+ ],
2633
+ "additionalProperties": false,
2634
+ "properties": {
2635
+ "activityId": {
2636
+ "type": "string",
2637
+ "pattern": "^A-\\d{3,}$"
2638
+ },
2639
+ "kind": {
2640
+ "type": "string",
2641
+ "enum": [
2642
+ "worker-dispatched",
2643
+ "worker-completed",
2644
+ "verification-round-completed",
2645
+ "self-fix-applied",
2646
+ "user-decision-required",
2647
+ "user-decision-evaluated"
2648
+ ]
2649
+ },
2650
+ "agent": {
2651
+ "type": "string",
2652
+ "minLength": 1
2653
+ },
2654
+ "summary": {
2655
+ "type": "string",
2656
+ "minLength": 1
2657
+ },
2658
+ "planItemIds": {
2659
+ "type": "array",
2660
+ "items": {
2661
+ "type": "string",
2662
+ "minLength": 1
2663
+ }
2664
+ },
2665
+ "resultPath": {
2666
+ "type": "string"
2667
+ },
2668
+ "commands": {
2669
+ "type": "array",
2670
+ "items": {
2671
+ "$ref": "#/$defs/ActivityCommand"
2672
+ }
2673
+ },
2674
+ "evidenceRefs": {
2675
+ "type": "array",
2676
+ "items": {
2677
+ "type": "string",
2678
+ "minLength": 1
2679
+ }
2680
+ },
2681
+ "outcome": {
2682
+ "type": "string",
2683
+ "enum": [
2684
+ "pending",
2685
+ "completed",
2686
+ "failed",
2687
+ "blocked",
2688
+ "resolved"
2689
+ ]
2690
+ }
2691
+ }
2692
+ },
2590
2693
  "TaskType": {
2591
2694
  "enum": [
2592
2695
  "requirements-discovery",
@@ -3553,6 +3656,86 @@
3553
3656
  "deferred-no-interactive-session"
3554
3657
  ]
3555
3658
  },
3659
+ "ApprovalDisposition": {
3660
+ "enum": [
3661
+ "select",
3662
+ "accept-risk",
3663
+ "request-revision",
3664
+ "reject"
3665
+ ]
3666
+ },
3667
+ "ApprovalResolution": {
3668
+ "type": "object",
3669
+ "required": [
3670
+ "disposition",
3671
+ "userText",
3672
+ "checkRefs"
3673
+ ],
3674
+ "additionalProperties": false,
3675
+ "properties": {
3676
+ "disposition": {
3677
+ "$ref": "#/$defs/ApprovalDisposition"
3678
+ },
3679
+ "userText": {
3680
+ "type": "string",
3681
+ "minLength": 1
3682
+ },
3683
+ "checkRefs": {
3684
+ "type": "array",
3685
+ "minItems": 1,
3686
+ "items": {
3687
+ "type": "string",
3688
+ "pattern": "^A-\\d{3,}$"
3689
+ }
3690
+ }
3691
+ }
3692
+ },
3693
+ "ApprovalContext": {
3694
+ "type": "object",
3695
+ "required": [
3696
+ "classification",
3697
+ "planItemIds",
3698
+ "activityIds",
3699
+ "unblockCondition",
3700
+ "recommendedDisposition"
3701
+ ],
3702
+ "additionalProperties": false,
3703
+ "properties": {
3704
+ "classification": {
3705
+ "enum": [
3706
+ "user-decision",
3707
+ "noncritical-dissent",
3708
+ "correctness-critical"
3709
+ ]
3710
+ },
3711
+ "planItemIds": {
3712
+ "type": "array",
3713
+ "minItems": 1,
3714
+ "items": {
3715
+ "type": "string",
3716
+ "pattern": "^P-[A-Za-z]+-.+"
3717
+ }
3718
+ },
3719
+ "activityIds": {
3720
+ "type": "array",
3721
+ "minItems": 1,
3722
+ "items": {
3723
+ "type": "string",
3724
+ "pattern": "^A-\\d{3,}$"
3725
+ }
3726
+ },
3727
+ "unblockCondition": {
3728
+ "type": "string",
3729
+ "minLength": 1
3730
+ },
3731
+ "recommendedDisposition": {
3732
+ "$ref": "#/$defs/ApprovalDisposition"
3733
+ },
3734
+ "resolution": {
3735
+ "$ref": "#/$defs/ApprovalResolution"
3736
+ }
3737
+ }
3738
+ },
3556
3739
  "ClarificationOption": {
3557
3740
  "type": "object",
3558
3741
  "required": [
@@ -3593,6 +3776,9 @@
3593
3776
  "directionChange": {
3594
3777
  "type": "string",
3595
3778
  "minLength": 1
3779
+ },
3780
+ "disposition": {
3781
+ "$ref": "#/$defs/ApprovalDisposition"
3596
3782
  }
3597
3783
  }
3598
3784
  },
@@ -8421,6 +8607,9 @@
8421
8607
  "items": {
8422
8608
  "$ref": "#/$defs/ClarificationOption"
8423
8609
  }
8610
+ },
8611
+ "approvalContext": {
8612
+ "$ref": "#/$defs/ApprovalContext"
8424
8613
  }
8425
8614
  }
8426
8615
  },
@@ -73,7 +73,7 @@ okstra user-response show --report <reportPath>
73
73
 
74
74
  Returns `{reportPath, rows: [{id, kind, blocks, status, statement, expectedForm, options, contextRefs, resolvedRefs}]}`.
75
75
 
76
- Each entry of `options[]` is `{role, answer, rationale, scopeImpact, addedWork, directionChange}`. `role` is `recommended` for exactly one entry and `alternative` for the rest; `scopeImpact` is a token list drawn from `in-repo` / `cross-repo` / `new-schema` / `deferrable`; `addedWork` and `directionChange` are one sentence each. A schema-v1 report has nowhere to record impact, so those three arrive empty — the CLI reconstructs only the answers from the report's `Expected form` cell.
76
+ Each entry of `options[]` is `{role, answer, rationale, scopeImpact, addedWork, directionChange, disposition}`. `role` is `recommended` for exactly one entry and `alternative` for the rest; `scopeImpact` is a token list drawn from `in-repo` / `cross-repo` / `new-schema` / `deferrable`; `addedWork` and `directionChange` are one sentence each. Activity-contract v1 approval options also carry the gate action in `disposition`. A legacy schema-v1 report has nowhere to record impact or a gate action, so those fields arrive empty — the CLI reconstructs only the answers from the report's `Expected form` cell.
77
77
 
78
78
  `resolvedRefs` is `[{ref, definition}]` — the CLI has already looked up what each internal token (`RB-002`, `FU-001`, `§4.7`, …) means in the report body; `definition` is `null` only when the report text alone could not resolve it (e.g. a `path:line` pointer).
79
79
 
@@ -127,7 +127,7 @@ Record one entry — `{id, kind, value, rationale?, disposition}`, `kind` copied
127
127
 
128
128
  | The user picks | `value` | `disposition` |
129
129
  |---|---|---|
130
- | One of the `options[]` entries | that option's `answer` text, not its short label | `answer` |
130
+ | One of the `options[]` entries | that option's `answer` text, not its short label | that option's `disposition` (`answer` only when absent on a legacy option) |
131
131
  | `Enter directly` → their own answer | the user's utterance verbatim (rationale into `rationale`) | `answer` |
132
132
  | Free text asking for the item to be re-asked | what the user wants re-asked, verbatim (empty → the raw statement) | `reframe` |
133
133
 
@@ -110,6 +110,14 @@ schema-version: {{ schemaVersion | yaml_scalar }}
110
110
  {{ md_rest(aiTaskProperty, 3) }}
111
111
  {% endif %}
112
112
 
113
+ {% if md_has("agentActivity") %}
114
+ ## {{ t("agentActivity.heading") }}
115
+
116
+ {{ t("agentActivity.intro") }}
117
+
118
+ {{ md("agentActivity", 3) }}
119
+ {% endif %}
120
+
113
121
  ## Cross Verification Audit
114
122
 
115
123
  {{ md("crossVerification", 3) }}