okstra 0.179.2 → 0.183.2

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 (258) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +30 -2
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/incremental-carry.mjs +9 -8
  5. package/dist/commands/execute/incremental-carry.mjs.map +1 -1
  6. package/dist/commands/execute/plan-verify.mjs +3 -1
  7. package/dist/commands/execute/plan-verify.mjs.map +1 -1
  8. package/dist/commands/execute/render-bundle.d.mts +4 -2
  9. package/dist/commands/execute/render-bundle.mjs +46 -5
  10. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  11. package/dist/commands/execute/run.mjs +11 -3
  12. package/dist/commands/execute/run.mjs.map +1 -1
  13. package/dist/commands/inspect/model-io.d.mts +1 -0
  14. package/dist/commands/inspect/model-io.mjs +25 -0
  15. package/dist/commands/inspect/model-io.mjs.map +1 -0
  16. package/dist/commands/inspect/stage-map.mjs +29 -8
  17. package/dist/commands/inspect/stage-map.mjs.map +1 -1
  18. package/dist/commands/inspect/task-list.mjs +52 -6
  19. package/dist/commands/inspect/task-list.mjs.map +1 -1
  20. package/dist/commands/inspect/user-response.mjs +14 -4
  21. package/dist/commands/inspect/user-response.mjs.map +1 -1
  22. package/dist/commands/lifecycle/check-project.d.mts +1 -0
  23. package/dist/commands/lifecycle/check-project.mjs +69 -50
  24. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  25. package/dist/commands/lifecycle/contract-check.d.mts +1 -0
  26. package/dist/commands/lifecycle/contract-check.mjs +18 -0
  27. package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
  28. package/dist/commands/lifecycle/preflight.mjs +154 -51
  29. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  30. package/dist/commands/pr/pr.d.mts +1 -0
  31. package/dist/commands/pr/pr.mjs +19 -1
  32. package/dist/commands/pr/pr.mjs.map +1 -1
  33. package/dist/commands/report/agent-activity.mjs +2 -2
  34. package/dist/commands/report/approval-decision.d.mts +1 -0
  35. package/dist/commands/report/approval-decision.mjs +21 -0
  36. package/dist/commands/report/approval-decision.mjs.map +1 -0
  37. package/dist/commands/report/design-snapshot.d.mts +1 -0
  38. package/dist/commands/report/design-snapshot.mjs +19 -0
  39. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  40. package/dist/commands/report/translate.mjs +3 -0
  41. package/dist/commands/report/translate.mjs.map +1 -1
  42. package/dist/lib/host-registry-client.mjs +13 -9
  43. package/dist/lib/host-registry-client.mjs.map +1 -1
  44. package/docs/architecture/storage-model.md +1 -1
  45. package/docs/architecture.md +21 -10
  46. package/docs/cli.md +36 -20
  47. package/docs/container.md +6 -4
  48. package/docs/contributor-change-matrix.md +1 -1
  49. package/docs/for-ai/README.md +2 -2
  50. package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
  51. package/docs/for-ai/skills/okstra-code-review.md +4 -4
  52. package/docs/for-ai/skills/okstra-container-build.md +20 -17
  53. package/docs/for-ai/skills/okstra-inspect.md +20 -23
  54. package/docs/for-ai/skills/okstra-manager.md +19 -18
  55. package/docs/for-ai/skills/okstra-memory.md +2 -2
  56. package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
  57. package/docs/for-ai/skills/okstra-rollup.md +14 -13
  58. package/docs/for-ai/skills/okstra-run.md +7 -3
  59. package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
  60. package/docs/for-ai/skills/okstra-setup.md +7 -7
  61. package/docs/for-ai/skills/okstra-usage.md +5 -4
  62. package/docs/for-ai/skills/okstra-user-response.md +50 -32
  63. package/docs/project-structure-overview.md +43 -31
  64. package/docs/task-process/implementation-planning.md +2 -2
  65. package/docs/task-process/implementation.md +7 -4
  66. package/package.json +1 -1
  67. package/runtime/BUILD.json +2 -2
  68. package/runtime/agents/workers/claude-worker.md +4 -4
  69. package/runtime/agents/workers/report-writer-worker.md +15 -164
  70. package/runtime/agents/workers/translator-worker.md +5 -13
  71. package/runtime/bin/okstra-error-log.py +51 -11
  72. package/runtime/bin/okstra-report-translate.py +210 -23
  73. package/runtime/prompts/host-orchestration/implementation.md +1 -1
  74. package/runtime/prompts/launch.template.md +9 -12
  75. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  76. package/runtime/prompts/lead/context-loader.md +41 -141
  77. package/runtime/prompts/lead/convergence.md +10 -8
  78. package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
  79. package/runtime/prompts/lead/plan-body-verification.md +217 -23
  80. package/runtime/prompts/lead/report-writer.md +81 -421
  81. package/runtime/prompts/lead/team-contract.md +9 -54
  82. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  83. package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
  84. package/runtime/prompts/profiles/_common-contract.md +4 -4
  85. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  86. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  87. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  88. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  89. package/runtime/prompts/profiles/error-analysis.md +1 -1
  90. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  91. package/runtime/prompts/profiles/final-verification.md +1 -1
  92. package/runtime/prompts/profiles/implementation-planning.md +20 -12
  93. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  94. package/runtime/prompts/profiles/project-analysis.md +1 -1
  95. package/runtime/prompts/profiles/release-handoff.md +0 -1
  96. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  97. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  98. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
  100. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +8 -2
  101. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  102. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
  103. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  104. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  105. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
  106. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
  107. package/runtime/python/okstra_ctl/agent_activity.py +141 -38
  108. package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
  109. package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
  110. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
  111. package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
  112. package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
  113. package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
  114. package/runtime/python/okstra_ctl/backfill.py +13 -10
  115. package/runtime/python/okstra_ctl/batch.py +2 -4
  116. package/runtime/python/okstra_ctl/build_tools.py +6 -3
  117. package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
  118. package/runtime/python/okstra_ctl/clarification_items.py +27 -13
  119. package/runtime/python/okstra_ctl/cmux.py +130 -52
  120. package/runtime/python/okstra_ctl/code_review_target.py +34 -8
  121. package/runtime/python/okstra_ctl/consumers.py +5 -4
  122. package/runtime/python/okstra_ctl/container.py +103 -8
  123. package/runtime/python/okstra_ctl/context_cost.py +2 -1
  124. package/runtime/python/okstra_ctl/contract_graph.py +497 -0
  125. package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
  126. package/runtime/python/okstra_ctl/convergence.py +338 -17
  127. package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
  128. package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
  129. package/runtime/python/okstra_ctl/convergence_store.py +55 -34
  130. package/runtime/python/okstra_ctl/design_prep.py +7 -4
  131. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  132. package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
  133. package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
  134. package/runtime/python/okstra_ctl/doctor.py +6 -3
  135. package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
  136. package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
  137. package/runtime/python/okstra_ctl/error_log_write.py +35 -30
  138. package/runtime/python/okstra_ctl/error_report.py +26 -1
  139. package/runtime/python/okstra_ctl/error_zip.py +27 -5
  140. package/runtime/python/okstra_ctl/execution_identity.py +3 -2
  141. package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
  142. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  143. package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
  144. package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
  145. package/runtime/python/okstra_ctl/fixed_text.py +39 -0
  146. package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
  147. package/runtime/python/okstra_ctl/handoff.py +5 -4
  148. package/runtime/python/okstra_ctl/i18n.py +4 -2
  149. package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
  150. package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
  151. package/runtime/python/okstra_ctl/incremental_carry.py +130 -17
  152. package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
  153. package/runtime/python/okstra_ctl/index.py +8 -11
  154. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
  155. package/runtime/python/okstra_ctl/invocation.py +3 -6
  156. package/runtime/python/okstra_ctl/json_boundary.py +366 -0
  157. package/runtime/python/okstra_ctl/json_registry.py +10 -12
  158. package/runtime/python/okstra_ctl/jsonl.py +19 -2
  159. package/runtime/python/okstra_ctl/lead_events.py +33 -1
  160. package/runtime/python/okstra_ctl/listing.py +3 -3
  161. package/runtime/python/okstra_ctl/log_report.py +24 -2
  162. package/runtime/python/okstra_ctl/manager_cli.py +92 -7
  163. package/runtime/python/okstra_ctl/manager_store.py +12 -10
  164. package/runtime/python/okstra_ctl/material.py +5 -1
  165. package/runtime/python/okstra_ctl/migrate.py +29 -25
  166. package/runtime/python/okstra_ctl/model_cli.py +3 -15
  167. package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
  168. package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
  169. package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
  170. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  171. package/runtime/python/okstra_ctl/paths.py +21 -0
  172. package/runtime/python/okstra_ctl/plan_items.py +115 -4
  173. package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
  174. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  175. package/runtime/python/okstra_ctl/pr_template.py +3 -2
  176. package/runtime/python/okstra_ctl/project_meta.py +5 -7
  177. package/runtime/python/okstra_ctl/recap.py +5 -4
  178. package/runtime/python/okstra_ctl/reconcile.py +21 -27
  179. package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
  180. package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
  181. package/runtime/python/okstra_ctl/render.py +74 -16
  182. package/runtime/python/okstra_ctl/render_final_report.py +10 -8
  183. package/runtime/python/okstra_ctl/report_assembly.py +509 -0
  184. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  185. package/runtime/python/okstra_ctl/report_finalize.py +67 -32
  186. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  187. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  188. package/runtime/python/okstra_ctl/report_language.py +3 -2
  189. package/runtime/python/okstra_ctl/report_markdown.py +81 -8
  190. package/runtime/python/okstra_ctl/report_narrative.py +351 -0
  191. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  192. package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
  193. package/runtime/python/okstra_ctl/report_views.py +3 -2
  194. package/runtime/python/okstra_ctl/rollup.py +65 -4
  195. package/runtime/python/okstra_ctl/run.py +184 -65
  196. package/runtime/python/okstra_ctl/run_audit.py +3 -2
  197. package/runtime/python/okstra_ctl/run_context.py +6 -9
  198. package/runtime/python/okstra_ctl/run_index_row.py +2 -8
  199. package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
  200. package/runtime/python/okstra_ctl/schema_excerpt.py +15 -8
  201. package/runtime/python/okstra_ctl/session_transcript.py +27 -1
  202. package/runtime/python/okstra_ctl/set_work_status.py +64 -38
  203. package/runtime/python/okstra_ctl/stage_fix_carry.py +8 -6
  204. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  205. package/runtime/python/okstra_ctl/stage_map.py +96 -28
  206. package/runtime/python/okstra_ctl/stage_targets.py +3 -4
  207. package/runtime/python/okstra_ctl/team.py +3 -2
  208. package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
  209. package/runtime/python/okstra_ctl/time_report.py +51 -4
  210. package/runtime/python/okstra_ctl/usage_identity.py +2 -1
  211. package/runtime/python/okstra_ctl/usage_report.py +58 -4
  212. package/runtime/python/okstra_ctl/user_response.py +1431 -66
  213. package/runtime/python/okstra_ctl/wizard.py +11 -44
  214. package/runtime/python/okstra_ctl/work_categories.py +3 -2
  215. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  216. package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
  217. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
  218. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  219. package/runtime/python/okstra_ctl/worker_runner.py +14 -12
  220. package/runtime/python/okstra_ctl/workflow.py +2 -1
  221. package/runtime/python/okstra_ctl/worktree.py +3 -2
  222. package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
  223. package/runtime/python/okstra_ctl/write_policy.py +4 -2
  224. package/runtime/python/okstra_token_usage/antigravity.py +39 -12
  225. package/runtime/python/okstra_token_usage/collect.py +90 -38
  226. package/runtime/python/okstra_token_usage/grok.py +127 -0
  227. package/runtime/schemas/final-report-v2.0.schema.json +9 -0
  228. package/runtime/schemas/final-report-v3.0.schema.json +10219 -0
  229. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  230. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
  231. package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
  232. package/runtime/skills/okstra-code-review/SKILL.md +21 -11
  233. package/runtime/skills/okstra-container-build/SKILL.md +18 -18
  234. package/runtime/skills/okstra-inspect/SKILL.md +12 -11
  235. package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
  236. package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
  237. package/runtime/skills/okstra-inspect/facets/history.md +9 -14
  238. package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
  239. package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
  240. package/runtime/skills/okstra-inspect/facets/report.md +6 -10
  241. package/runtime/skills/okstra-inspect/facets/status.md +9 -8
  242. package/runtime/skills/okstra-inspect/facets/time.md +3 -3
  243. package/runtime/skills/okstra-manager/SKILL.md +16 -14
  244. package/runtime/skills/okstra-memory/SKILL.md +3 -3
  245. package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
  246. package/runtime/skills/okstra-rollup/SKILL.md +6 -16
  247. package/runtime/skills/okstra-run/SKILL.md +7 -7
  248. package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
  249. package/runtime/skills/okstra-setup/SKILL.md +21 -13
  250. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  251. package/runtime/skills/okstra-usage/SKILL.md +10 -10
  252. package/runtime/skills/okstra-user-response/SKILL.md +78 -107
  253. package/runtime/templates/report-writer-prompt-preamble.md +29 -19
  254. package/runtime/templates/reports/html/macros/forms.html +6 -4
  255. package/runtime/templates/reports/schedule.template.md +4 -4
  256. package/runtime/templates/worker-error-contract.md +17 -29
  257. package/runtime/validators/validate-run.py +636 -72
  258. package/runtime/validators/validate_session_conformance.py +43 -7
@@ -9,31 +9,57 @@ the ``## PLAN DECISION`` block used by the implementation wizard and the optiona
9
9
  from __future__ import annotations
10
10
 
11
11
  import argparse
12
+ import base64
12
13
  import datetime as dt
14
+ import hashlib
13
15
  import json
16
+ import os
14
17
  import re
15
18
  import stat
16
19
  import sys
20
+ import tempfile
21
+ import uuid
22
+ from collections.abc import Mapping
23
+ from contextlib import contextmanager
17
24
  from dataclasses import dataclass
18
25
  from pathlib import Path
19
- from typing import Optional
26
+ from typing import Any, Iterator, Optional
20
27
 
21
28
  from okstra_ctl.report_views import (
22
29
  PLAN_DECISION_APPROVED,
30
+ RunMeta,
23
31
  normalize_direction_selection_identity,
24
32
  serialize_user_response, UserResponseEntry, UserPlanDecision,
25
- UserReportAuthoring, infer_run_meta,
33
+ UserReportAuthoring, UserResponseAnalysisReview, UserDirectionSelection,
34
+ infer_run_meta,
26
35
  parse_expected_form_options,
36
+ resolve_recommended_option,
27
37
  )
28
38
  from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
39
+ from okstra_ctl.final_report_paths import (
40
+ final_report_data_path,
41
+ final_report_markdown_path,
42
+ is_report_record_path,
43
+ )
44
+ from okstra_ctl.json_boundary import (
45
+ JsonBoundaryError,
46
+ load_owned_object,
47
+ write_owned_object_atomic,
48
+ )
49
+ from okstra_ctl.final_report_schema import (
50
+ SchemaError,
51
+ load_schema_version,
52
+ validate as validate_report_schema,
53
+ )
54
+ from okstra_ctl.ids import slugify_task_segment
29
55
  from okstra_ctl.listing import list_runs, absolute_final_report_path
56
+ from okstra_ctl.paths import resolve_under_root
57
+ from okstra_ctl.run_context import dir_flock
30
58
  from okstra_ctl.clarification_items import (
59
+ ClarificationItem,
31
60
  read_clarification_rows,
32
- scan_open_user_input,
33
- section_1_present_but_unparsed,
34
61
  sidecar_answers,
35
62
  _section_1_slice,
36
- _v2_report_data,
37
63
  )
38
64
 
39
65
  _PLAN_DECISION_HEADING_RE = re.compile(r"^## PLAN DECISION\s*$", re.MULTILINE)
@@ -42,6 +68,9 @@ _ANALYSIS_REVIEW_HEADING_RE = re.compile(r"^## ANALYSIS REVIEW\s*$", re.MULTILIN
42
68
  _DIRECTION_SELECTION_HEADING_RE = re.compile(
43
69
  r"^## DIRECTION SELECTION\s*$", re.MULTILINE
44
70
  )
71
+ _REPORT_AUTHORING_HEADING_RE = re.compile(
72
+ r"^## REPORT AUTHORING\s*$", re.MULTILINE
73
+ )
45
74
  _ANALYSIS_SIDECAR_HEADING_RE = re.compile(
46
75
  r"^## (?P<filename>user-response-[^\n]+\.md)\s*$", re.MULTILINE
47
76
  )
@@ -425,6 +454,25 @@ def parse_plan_decision(sidecar_text: str) -> Optional[PlanDecisionRecord]:
425
454
  )
426
455
 
427
456
 
457
+ def parse_report_authoring(sidecar_text: str) -> UserReportAuthoring | None:
458
+ matches = list(_REPORT_AUTHORING_HEADING_RE.finditer(sidecar_text))
459
+ if not matches:
460
+ return None
461
+ if len(matches) != 1:
462
+ raise UserResponseError("REPORT AUTHORING requires exactly one block")
463
+ block = sidecar_text[matches[0].end():]
464
+ next_heading = _NEXT_RESPONSE_HEADING_RE.search(block)
465
+ if next_heading:
466
+ block = block[:next_heading.start()]
467
+ status = _field(block, "Status")
468
+ reason = _quoted_review_value(block, "Reason")
469
+ if status not in {"approved", "denied"} or not reason:
470
+ raise UserResponseError(
471
+ "REPORT AUTHORING requires approved/denied Status and Reason"
472
+ )
473
+ return UserReportAuthoring(status=status, reason=reason)
474
+
475
+
428
476
  _RESPONSE_HEADING_RE = re.compile(r"^## (?P<id>[A-Za-z][A-Za-z0-9]*-\d+)\s*$", re.MULTILINE)
429
477
 
430
478
 
@@ -531,6 +579,326 @@ def _seq_from_report(report: Path) -> str:
531
579
  return m.group(1) if m else ""
532
580
 
533
581
 
582
+ @dataclass(frozen=True)
583
+ class ResponseReportContext:
584
+ report_path: Path
585
+ markdown_path: Path
586
+ report_contract_version: str
587
+ task_key: str
588
+ task_type: str
589
+ seq: str
590
+ source_report: str
591
+ source_data: str
592
+ source_data_sha256: str
593
+ sidecar_path: Path
594
+ project_root: Path
595
+ task_root: Path
596
+ run_root: Path
597
+
598
+
599
+ @dataclass(frozen=True)
600
+ class ExistingSidecarState:
601
+ entries: tuple[UserResponseEntry, ...]
602
+ plan_decision: UserPlanDecision | None
603
+ analysis_review: UserResponseAnalysisReview | None
604
+ direction_selection: UserDirectionSelection | None
605
+ report_authoring: UserReportAuthoring | None
606
+ run_meta: RunMeta | None
607
+ created_at: str
608
+
609
+
610
+ def _load_report_record(
611
+ report_path: Path, *, validate_schema: bool = False
612
+ ) -> dict[str, Any] | None:
613
+ data_path = final_report_data_path(report_path)
614
+ if not data_path.is_file():
615
+ if is_report_record_path(report_path):
616
+ raise UserResponseError(f"report record is missing: {data_path}")
617
+ return None
618
+ try:
619
+ record = load_owned_object(data_path, artifact="final report record")
620
+ except JsonBoundaryError as exc:
621
+ raise UserResponseError(str(exc)) from exc
622
+ version = record.get("schemaVersion")
623
+ if version not in {"2.0", "3.0"}:
624
+ raise UserResponseError(f"unsupported report contract: {version}")
625
+ if validate_schema:
626
+ try:
627
+ errors = validate_report_schema(record, load_schema_version(str(version)))
628
+ except SchemaError as exc:
629
+ raise UserResponseError(f"report schema could not be loaded: {exc}") from exc
630
+ if errors:
631
+ raise UserResponseError(f"report schema validation failed: {errors[0]}")
632
+ return record
633
+
634
+
635
+ def _relative_from_runs(path: Path) -> str:
636
+ indices = [index for index, part in enumerate(path.parts) if part == "runs"]
637
+ return Path(*path.parts[indices[-1]:]).as_posix() if indices else path.name
638
+
639
+
640
+ def _record_identity(record: Mapping[str, Any]) -> tuple[str, str]:
641
+ header = record.get("header")
642
+ if not isinstance(header, Mapping):
643
+ raise UserResponseError("report record header is missing")
644
+ task_key = header.get("taskKey")
645
+ task_type = header.get("taskType")
646
+ if not isinstance(task_key, str) or not task_key.strip():
647
+ raise UserResponseError("report record header.taskKey is missing")
648
+ if not isinstance(task_type, str) or not task_type.strip():
649
+ raise UserResponseError("report record header.taskType is missing")
650
+ return task_key.strip(), task_type.strip()
651
+
652
+
653
+ def resolve_report_context(
654
+ report_path: Path, *, expected_task_key: str = ""
655
+ ) -> ResponseReportContext:
656
+ try:
657
+ resolved = report_path.resolve(strict=True)
658
+ except OSError as exc:
659
+ raise UserResponseError(f"report is unreadable: {report_path}") from exc
660
+ if resolved.parent.name != "reports":
661
+ raise UserResponseError("report must stay under an okstra reports directory")
662
+ record = _load_report_record(resolved)
663
+ markdown = final_report_markdown_path(resolved) if record else resolved
664
+ if record is None:
665
+ inferred = infer_run_meta(markdown, task_key=expected_task_key or None)
666
+ task_key, task_type = inferred.task_key, inferred.task_type
667
+ version = "1.0"
668
+ else:
669
+ task_key, task_type = _record_identity(record)
670
+ version = str(record["schemaVersion"])
671
+ if expected_task_key and task_key != expected_task_key:
672
+ raise UserResponseError(
673
+ f"task key does not match report: {expected_task_key} != {task_key}"
674
+ )
675
+ seq = _seq_from_report(resolved)
676
+ if not seq:
677
+ raise UserResponseError("report filename has no run sequence")
678
+ run_root = resolved.parent.parent
679
+ data_path = final_report_data_path(resolved) if record else None
680
+ source_data = _relative_from_runs(data_path) if data_path else ""
681
+ task_type_root = run_root.parent if run_root.name.startswith("stage-") else run_root
682
+ task_root = task_type_root.parent.parent
683
+ project_root = task_root.parents[3] if len(task_root.parents) >= 4 else task_root
684
+ return ResponseReportContext(
685
+ report_path=data_path or resolved,
686
+ markdown_path=markdown,
687
+ report_contract_version=version,
688
+ task_key=task_key,
689
+ task_type=task_type,
690
+ seq=seq,
691
+ source_report=_relative_from_runs(markdown),
692
+ source_data=source_data,
693
+ source_data_sha256=(
694
+ hashlib.sha256(data_path.read_bytes()).hexdigest() if data_path else ""
695
+ ),
696
+ sidecar_path=(
697
+ run_root
698
+ / "user-responses"
699
+ / f"user-response-{task_type}-{seq}.md"
700
+ ),
701
+ project_root=project_root,
702
+ task_root=task_root,
703
+ run_root=run_root,
704
+ )
705
+
706
+
707
+ def _lexical_absolute(path: Path) -> Path:
708
+ return Path(os.path.abspath(os.fspath(path.expanduser())))
709
+
710
+
711
+ def _reject_symlinks_below(root: Path, path: Path, label: str) -> None:
712
+ if root.is_symlink():
713
+ raise UserResponseError(f"{label} project root contains a symlink: {root}")
714
+ try:
715
+ relative = path.relative_to(root)
716
+ except ValueError as exc:
717
+ raise UserResponseError(f"{label} must stay under the project root") from exc
718
+ current = root
719
+ for part in relative.parts:
720
+ current = current / part
721
+ if not current.exists() and not current.is_symlink():
722
+ continue
723
+ try:
724
+ mode = current.lstat().st_mode
725
+ except OSError as exc:
726
+ raise UserResponseError(f"{label} path is unreadable: {current}") from exc
727
+ if stat.S_ISLNK(mode):
728
+ raise UserResponseError(f"{label} path contains a symlink: {current}")
729
+
730
+
731
+ def _validate_owned_report_context(
732
+ report_path: Path,
733
+ *,
734
+ expected_task_key: str = "",
735
+ expected_project_root: Path | None = None,
736
+ ) -> ResponseReportContext:
737
+ lexical = _lexical_absolute(report_path)
738
+ data_path = _lexical_absolute(final_report_data_path(lexical))
739
+ if data_path.parent.name != "reports":
740
+ raise UserResponseError("report must stay under an okstra reports directory")
741
+ run_root = data_path.parent.parent
742
+ task_type_root = run_root.parent if run_root.name.startswith("stage-") else run_root
743
+ task_root = task_type_root.parent.parent
744
+ if (
745
+ task_type_root.parent.name != "runs"
746
+ or task_root.parent.parent.name != "tasks"
747
+ or task_root.parent.parent.parent.name != ".okstra"
748
+ ):
749
+ raise UserResponseError(
750
+ "report must stay under <PROJECT_ROOT>/.okstra/tasks/<group>/<task>/runs"
751
+ )
752
+ project_root = task_root.parent.parent.parent.parent
753
+ if expected_project_root is not None:
754
+ selected_root = _lexical_absolute(expected_project_root)
755
+ if selected_root.is_symlink() or not selected_root.is_dir():
756
+ raise UserResponseError(
757
+ f"selected project root must be a regular directory: {selected_root}"
758
+ )
759
+ if selected_root.resolve() != project_root.resolve():
760
+ raise UserResponseError("report does not belong to the selected project root")
761
+ relative = data_path.relative_to(project_root).as_posix()
762
+ if resolve_under_root(project_root, relative) is None:
763
+ raise UserResponseError("report resolves outside the selected project")
764
+ _reject_symlinks_below(project_root, data_path, "report")
765
+ markdown = _lexical_absolute(final_report_markdown_path(data_path))
766
+ _reject_symlinks_below(project_root, markdown, "report reading copy")
767
+ if not data_path.is_file() or not markdown.is_file():
768
+ raise UserResponseError("report record and reading copy must be regular files")
769
+ record = _load_report_record(data_path, validate_schema=True)
770
+ if record is None:
771
+ raise UserResponseError("typed transactions require a versioned report record")
772
+ context = resolve_report_context(data_path, expected_task_key=expected_task_key)
773
+ task_key_parts = context.task_key.split(":")
774
+ if len(task_key_parts) < 3:
775
+ raise UserResponseError("report task key must contain project, group, and task")
776
+ project_id = ":".join(task_key_parts[:-2])
777
+ project_config = project_root / ".okstra" / "project.json"
778
+ _reject_symlinks_below(project_root, project_config, "project config")
779
+ try:
780
+ config = load_owned_object(project_config, artifact="okstra project config")
781
+ except JsonBoundaryError as exc:
782
+ raise UserResponseError(str(exc)) from exc
783
+ frontmatter = record.get("frontmatter")
784
+ report_project_id = (
785
+ str(frontmatter.get("projectId") or "")
786
+ if isinstance(frontmatter, Mapping)
787
+ else ""
788
+ )
789
+ if config.get("projectId") != project_id or report_project_id != project_id:
790
+ raise UserResponseError("report project id does not match its project owner")
791
+ if (
792
+ slugify_task_segment(task_key_parts[-2]) != task_root.parent.name
793
+ or slugify_task_segment(task_key_parts[-1]) != task_root.name
794
+ ):
795
+ raise UserResponseError("report task key does not match its task directory")
796
+ if context.task_type != task_type_root.name:
797
+ raise UserResponseError("report task type does not match its run directory")
798
+ expected_name = f"final-report-{context.task_type}-{context.seq}.data.json"
799
+ if data_path.name != expected_name:
800
+ raise UserResponseError("report filename does not match its task type and run sequence")
801
+ if context.project_root != project_root.resolve():
802
+ raise UserResponseError("report project root does not match its canonical owner")
803
+ for owned_dir in (run_root / "state", run_root / "user-responses"):
804
+ _reject_symlinks_below(project_root, owned_dir, "user-response target")
805
+ if owned_dir.exists() and not owned_dir.is_dir():
806
+ raise UserResponseError(f"user-response target must be a directory: {owned_dir}")
807
+ _reject_symlinks_below(project_root, context.sidecar_path, "sidecar")
808
+ return context
809
+
810
+
811
+ def _record_clarification_rows(record: Mapping[str, Any]) -> list[dict[str, Any]]:
812
+ entries = record.get("clarificationItems")
813
+ if not isinstance(entries, list):
814
+ raise UserResponseError("report clarificationItems must be an array")
815
+ rows: list[dict[str, Any]] = []
816
+ for entry in entries:
817
+ if not isinstance(entry, Mapping):
818
+ raise UserResponseError("report clarification item must be an object")
819
+ row_id, kind = entry.get("id"), entry.get("kind")
820
+ blocks, status = entry.get("blocks"), entry.get("status")
821
+ if not all(isinstance(value, str) and value for value in (row_id, blocks, status)):
822
+ raise UserResponseError("report clarification item identity is invalid")
823
+ item = ClarificationItem(
824
+ row_id=row_id,
825
+ kind=kind.lower() if isinstance(kind, str) else "",
826
+ blocks=blocks.lower(),
827
+ status=status.lower(),
828
+ raw_blocks=blocks,
829
+ raw_status=status,
830
+ )
831
+ options = entry.get("options")
832
+ rows.append({
833
+ "item": item,
834
+ "statement": str(entry.get("statement") or ""),
835
+ "expected_form": str(entry.get("expectedForm") or ""),
836
+ "options": (
837
+ [dict(option) for option in options if isinstance(option, Mapping)]
838
+ if isinstance(options, list)
839
+ else []
840
+ ),
841
+ "approval_context": (
842
+ dict(entry["approvalContext"])
843
+ if isinstance(entry.get("approvalContext"), Mapping)
844
+ else {}
845
+ ),
846
+ })
847
+ return rows
848
+
849
+
850
+ def _all_report_rows(report_path: Path) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
851
+ record = _load_report_record(report_path)
852
+ return (
853
+ (_record_clarification_rows(record), record)
854
+ if record is not None
855
+ else (read_clarification_rows(report_path), None)
856
+ )
857
+
858
+
859
+ def _implementation_option_projection(
860
+ context: ResponseReportContext,
861
+ ) -> tuple[list[str], str]:
862
+ record = _load_report_record(context.report_path) or {}
863
+ planning = record.get("implementationPlanning")
864
+ candidates = [
865
+ str(candidate.get("name"))
866
+ for candidate in (
867
+ planning.get("optionCandidates")
868
+ if isinstance(planning, Mapping)
869
+ else []
870
+ ) or []
871
+ if isinstance(candidate, Mapping) and candidate.get("name")
872
+ ]
873
+ recommended = planning.get("recommendedOption") if isinstance(planning, Mapping) else None
874
+ recommended_name = resolve_recommended_option(
875
+ str(recommended.get("name") or "") if isinstance(recommended, Mapping) else "",
876
+ tuple(candidates),
877
+ ) if candidates else ""
878
+ return candidates, recommended_name
879
+
880
+
881
+ def _open_blocker_rows(report_path: Path) -> list[dict[str, Any]]:
882
+ rows, _ = _all_report_rows(report_path)
883
+ answered = sidecar_answers(report_path)
884
+ return [
885
+ row
886
+ for row in rows
887
+ if row["item"].blocks in {"approval", "next-phase"}
888
+ and row["item"].status in {"open", "answered"}
889
+ and row["item"].row_id not in answered
890
+ ]
891
+
892
+
893
+ def _plan_decision_required(context: ResponseReportContext) -> bool:
894
+ if context.task_type != "implementation-planning":
895
+ return False
896
+ candidates, _ = _implementation_option_projection(context)
897
+ if not candidates:
898
+ return False
899
+ return _existing_sidecar_state(context.sidecar_path).plan_decision is None
900
+
901
+
534
902
  def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
535
903
  """사용자 답변을 기다리는 clarification 이 있는 태스크를 최신 report mtime 순으로.
536
904
 
@@ -550,22 +918,33 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
550
918
  report = absolute_final_report_path(row)
551
919
  if report is None or not report.is_file():
552
920
  continue
553
- text = report.read_text(encoding="utf-8")
554
- scan = scan_open_user_input(report)
555
- base = {"taskKey": key, "taskType": row.get("taskType", ""),
556
- "seq": _seq_from_report(report), "reportPath": str(report),
557
- "reportMtime": report.stat().st_mtime}
558
- if scan.unreadable_reason is not None:
559
- # §1 heading exists but drifted → real warning; §1 simply absent → skip.
560
- if section_1_present_but_unparsed(text):
561
- out.append({**base, "openBlockerCount": 0, "openApprovalCount": 0,
562
- "unreadable": True})
921
+ try:
922
+ context = resolve_report_context(report)
923
+ blockers = _open_blocker_rows(context.report_path)
924
+ plan_required = _plan_decision_required(context)
925
+ except UserResponseError:
926
+ base = {"taskKey": key, "taskType": row.get("taskType", ""),
927
+ "seq": _seq_from_report(report), "reportPath": str(report),
928
+ "reportMtime": report.stat().st_mtime}
929
+ out.append({**base, "openBlockerCount": 0, "openApprovalCount": 0,
930
+ "unreadable": True})
563
931
  continue
564
- if not scan.blockers:
932
+ base = {
933
+ "taskKey": key,
934
+ "canonicalTaskKey": context.task_key,
935
+ "taskType": context.task_type,
936
+ "seq": context.seq,
937
+ "reportPath": str(report),
938
+ "normalizedReportPath": str(context.report_path),
939
+ "reportMtime": context.report_path.stat().st_mtime,
940
+ }
941
+ if not blockers and not plan_required:
565
942
  continue
566
- approval_count = sum(1 for it in scan.blockers if it.blocks == "approval")
567
- out.append({**base, "openBlockerCount": len(scan.blockers),
568
- "openApprovalCount": approval_count, "unreadable": False})
943
+ approval_count = sum(1 for row in blockers if row["item"].blocks == "approval")
944
+ out.append({**base, "openBlockerCount": len(blockers),
945
+ "openApprovalCount": approval_count,
946
+ "planDecisionRequired": plan_required,
947
+ "unreadable": False})
569
948
  out.sort(key=lambda t: t["reportMtime"], reverse=True)
570
949
  return out[:limit] if limit > 0 else out
571
950
 
@@ -729,11 +1108,16 @@ def resolve_refs_from_record(data: dict, refs: list[str]) -> list[dict]:
729
1108
  def show_open_rows(report_path: Path) -> dict:
730
1109
  # 사이드카에 답이 있는 행은 사용자가 이미 답한 것이다. 리포트의 `Status` 는
731
1110
  # 그 답을 반영하지 않으므로, 이걸 빼지 않으면 스킬이 같은 질문을 다시 묻는다.
732
- answered = sidecar_answers(report_path)
733
- record = _v2_report_data(report_path)
734
- v1_text = None if record is not None else report_path.read_text(encoding="utf-8")
1111
+ context = resolve_report_context(report_path)
1112
+ answered = sidecar_answers(context.report_path)
1113
+ report_rows, record = _all_report_rows(context.report_path)
1114
+ v1_text = (
1115
+ None
1116
+ if record is not None
1117
+ else context.markdown_path.read_text(encoding="utf-8")
1118
+ )
735
1119
  rows = []
736
- for r in read_clarification_rows(report_path):
1120
+ for r in report_rows:
737
1121
  it = r["item"]
738
1122
  if it.status not in ("open", "answered") or it.row_id in answered:
739
1123
  continue
@@ -750,7 +1134,933 @@ def show_open_rows(report_path: Path) -> dict:
750
1134
  "options": r["options"] or _options_from_expected_form(expected),
751
1135
  "contextRefs": refs,
752
1136
  "resolvedRefs": resolved})
753
- return {"reportPath": str(report_path), "rows": rows}
1137
+ return {
1138
+ "reportPath": str(report_path),
1139
+ "normalizedReportPath": str(context.report_path),
1140
+ "taskKey": context.task_key,
1141
+ "taskType": context.task_type,
1142
+ "reportContractVersion": context.report_contract_version,
1143
+ "rows": rows,
1144
+ }
1145
+
1146
+
1147
+ _TRANSACTION_PREFIX = "ur2."
1148
+ _TRANSACTION_ARTIFACT = "user-response transaction"
1149
+ _DIRECT_ANSWER_DISPOSITIONS = frozenset({"answer", "reframe"})
1150
+ _PLAN_DECISION_STATUSES = frozenset({
1151
+ "approved",
1152
+ "revision-requested",
1153
+ "rejected",
1154
+ })
1155
+ _SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
1156
+
1157
+
1158
+ def _unexpected_keys(value: Mapping[str, Any], allowed: set[str], label: str) -> list[str]:
1159
+ missing = sorted(allowed - set(value))
1160
+ unexpected = sorted(set(value) - allowed)
1161
+ errors = [f"{label} is missing fields: {', '.join(missing)}"] if missing else []
1162
+ if unexpected:
1163
+ errors.append(f"{label} has unexpected fields: {', '.join(unexpected)}")
1164
+ return errors
1165
+
1166
+
1167
+ def _valid_sha256(value: object) -> bool:
1168
+ return isinstance(value, str) and _SHA256_RE.fullmatch(value) is not None
1169
+
1170
+
1171
+ def _transaction_errors(payload: dict[str, Any]) -> list[str]:
1172
+ errors = _unexpected_keys(
1173
+ payload, {"schemaVersion", "transactionId", "anchor", "draft", "publication"},
1174
+ "transaction",
1175
+ )
1176
+ if payload.get("schemaVersion") != "2.0":
1177
+ errors.append("schemaVersion must be 2.0")
1178
+ if not isinstance(payload.get("transactionId"), str) or not payload["transactionId"]:
1179
+ errors.append("transactionId must be a non-empty string")
1180
+ anchor = payload.get("anchor")
1181
+ draft = payload.get("draft")
1182
+ publication = payload.get("publication")
1183
+ if not isinstance(anchor, Mapping):
1184
+ errors.append("anchor must be an object")
1185
+ else:
1186
+ anchor_fields = {
1187
+ "nonce", "transactionPath", "reportPath", "reportContractVersion",
1188
+ "taskKey", "taskType", "runSeq", "sourceReport", "sourceData",
1189
+ "sourceDataSha256", "sidecarPath", "baseSidecar", "createdAt",
1190
+ }
1191
+ errors.extend(_unexpected_keys(anchor, anchor_fields, "anchor"))
1192
+ for field in anchor_fields - {"baseSidecar"}:
1193
+ if not isinstance(anchor.get(field), str) or not anchor[field]:
1194
+ errors.append(f"anchor.{field} must be a non-empty string")
1195
+ if re.fullmatch(r"[0-9a-f]{32}", str(anchor.get("nonce") or "")) is None:
1196
+ errors.append("anchor.nonce must be 32 lowercase hexadecimal characters")
1197
+ if not _valid_sha256(anchor.get("sourceDataSha256")):
1198
+ errors.append("anchor.sourceDataSha256 must be 64 lowercase hexadecimal characters")
1199
+ base = anchor.get("baseSidecar")
1200
+ if not isinstance(base, Mapping):
1201
+ errors.append("anchor.baseSidecar must be an object")
1202
+ else:
1203
+ errors.extend(_unexpected_keys(base, {"exists", "sha256"}, "anchor.baseSidecar"))
1204
+ exists = base.get("exists")
1205
+ digest = base.get("sha256")
1206
+ if not isinstance(exists, bool):
1207
+ errors.append("anchor.baseSidecar.exists must be boolean")
1208
+ if exists is True and not _valid_sha256(digest):
1209
+ errors.append(
1210
+ "anchor.baseSidecar.sha256 must be 64 lowercase hexadecimal characters"
1211
+ )
1212
+ if exists is False and digest is not None:
1213
+ errors.append("anchor.baseSidecar.sha256 must be null when the sidecar is absent")
1214
+ if not isinstance(draft, Mapping):
1215
+ errors.append("draft must be an object")
1216
+ else:
1217
+ errors.extend(_unexpected_keys(
1218
+ draft, {"answers", "planDecision", "legacyReportAuthoring"}, "draft"
1219
+ ))
1220
+ answers = draft.get("answers")
1221
+ if not isinstance(answers, list):
1222
+ errors.append("draft.answers must be an array")
1223
+ else:
1224
+ for index, answer in enumerate(answers):
1225
+ label = f"draft.answers[{index}]"
1226
+ if not isinstance(answer, Mapping):
1227
+ errors.append(f"{label} must be an object")
1228
+ continue
1229
+ mode = answer.get("mode")
1230
+ allowed = (
1231
+ {"mode", "id", "kind", "optionNumber"}
1232
+ if mode == "option"
1233
+ else {"mode", "id", "kind", "value", "rationale", "disposition"}
1234
+ )
1235
+ errors.extend(_unexpected_keys(answer, allowed, label))
1236
+ if mode not in {"direct", "option"}:
1237
+ errors.append(f"{label}.mode must be direct or option")
1238
+ for field in ("id", "kind"):
1239
+ if not isinstance(answer.get(field), str) or not answer[field]:
1240
+ errors.append(f"{label}.{field} must be a non-empty string")
1241
+ if mode == "option":
1242
+ number = answer.get("optionNumber")
1243
+ if not isinstance(number, int) or isinstance(number, bool) or number < 1:
1244
+ errors.append(f"{label}.optionNumber must be a positive integer")
1245
+ elif mode == "direct":
1246
+ if not isinstance(answer.get("value"), str) or not answer["value"]:
1247
+ errors.append(f"{label}.value must be a non-empty string")
1248
+ if not isinstance(answer.get("rationale"), str):
1249
+ errors.append(f"{label}.rationale must be a string")
1250
+ if answer.get("disposition") not in _DIRECT_ANSWER_DISPOSITIONS:
1251
+ errors.append(f"{label}.disposition is invalid for a direct answer")
1252
+ for field in ("planDecision", "legacyReportAuthoring"):
1253
+ value = draft.get(field)
1254
+ if value is not None and not isinstance(value, Mapping):
1255
+ errors.append(f"draft.{field} must be an object or null")
1256
+ decision = draft.get("planDecision")
1257
+ if isinstance(decision, Mapping):
1258
+ errors.extend(_unexpected_keys(
1259
+ decision, {"status", "implementationOption", "reason"},
1260
+ "draft.planDecision",
1261
+ ))
1262
+ if decision.get("status") not in _PLAN_DECISION_STATUSES:
1263
+ errors.append("draft.planDecision.status is invalid")
1264
+ for field in ("implementationOption", "reason"):
1265
+ if not isinstance(decision.get(field), str):
1266
+ errors.append(f"draft.planDecision.{field} must be a string")
1267
+ if decision.get("status") != "approved" and not decision.get("reason"):
1268
+ errors.append("draft.planDecision.reason is required")
1269
+ authoring = draft.get("legacyReportAuthoring")
1270
+ if isinstance(authoring, Mapping):
1271
+ errors.extend(_unexpected_keys(
1272
+ authoring, {"status", "reason"}, "draft.legacyReportAuthoring"
1273
+ ))
1274
+ if authoring.get("status") not in {"approved", "denied"}:
1275
+ errors.append("draft.legacyReportAuthoring.status is invalid")
1276
+ if not isinstance(authoring.get("reason"), str) or not authoring["reason"]:
1277
+ errors.append("draft.legacyReportAuthoring.reason must be a non-empty string")
1278
+ if not isinstance(publication, Mapping):
1279
+ errors.append("publication must be an object")
1280
+ else:
1281
+ errors.extend(_unexpected_keys(
1282
+ publication, {"status", "intentSha256", "finalizedSha256"}, "publication"
1283
+ ))
1284
+ status_value = publication.get("status")
1285
+ intent = publication.get("intentSha256")
1286
+ finalized = publication.get("finalizedSha256")
1287
+ if status_value not in {"draft", "intent-recorded", "finalized"}:
1288
+ errors.append("publication.status is invalid")
1289
+ if status_value == "draft" and (intent is not None or finalized is not None):
1290
+ errors.append("publication draft digests must be null")
1291
+ if status_value == "intent-recorded" and (
1292
+ not _valid_sha256(intent) or finalized is not None
1293
+ ):
1294
+ errors.append("publication intent digest must be valid and finalized digest null")
1295
+ if status_value == "finalized" and (
1296
+ not _valid_sha256(intent) or not _valid_sha256(finalized) or intent != finalized
1297
+ ):
1298
+ errors.append("publication finalized digests must be matching sha256 values")
1299
+ return errors
1300
+
1301
+
1302
+ def _canonical_anchor_digest(anchor: Mapping[str, Any]) -> str:
1303
+ encoded = json.dumps(
1304
+ anchor, ensure_ascii=False, sort_keys=True, separators=(",", ":")
1305
+ ).encode("utf-8")
1306
+ return hashlib.sha256(encoded).hexdigest()
1307
+
1308
+
1309
+ def _encode_transaction_path(path: Path, anchor: Mapping[str, Any]) -> str:
1310
+ encoded = base64.urlsafe_b64encode(str(path).encode()).decode().rstrip("=")
1311
+ return f"{_TRANSACTION_PREFIX}{encoded}.{_canonical_anchor_digest(anchor)}"
1312
+
1313
+
1314
+ def _decode_transaction_token(transaction_id: str) -> tuple[Path, str]:
1315
+ if not transaction_id.startswith(_TRANSACTION_PREFIX):
1316
+ raise UserResponseError("invalid user-response transaction id")
1317
+ encoded_and_digest = transaction_id[len(_TRANSACTION_PREFIX):]
1318
+ try:
1319
+ encoded, anchor_digest = encoded_and_digest.rsplit(".", 1)
1320
+ decoded = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
1321
+ path = Path(decoded.decode())
1322
+ except (ValueError, UnicodeDecodeError) as exc:
1323
+ raise UserResponseError("invalid user-response transaction id") from exc
1324
+ if not _valid_sha256(anchor_digest):
1325
+ raise UserResponseError("invalid user-response transaction anchor digest")
1326
+ if (
1327
+ not path.is_absolute()
1328
+ or path.name != "draft.json"
1329
+ or path.parent.parent.name != "user-response-transactions"
1330
+ or path.parent.parent.parent.name != "state"
1331
+ or re.fullmatch(r"[0-9a-f]{32}", path.parent.name) is None
1332
+ ):
1333
+ raise UserResponseError("invalid user-response transaction path")
1334
+ return path, anchor_digest
1335
+
1336
+
1337
+ def _transaction_path(transaction_id: str) -> Path:
1338
+ path, _ = _decode_transaction_token(transaction_id)
1339
+ return path
1340
+
1341
+
1342
+ def _load_transaction(transaction_id: str) -> tuple[Path, dict[str, Any]]:
1343
+ path, expected_anchor_digest = _decode_transaction_token(transaction_id)
1344
+ try:
1345
+ payload = load_owned_object(
1346
+ path,
1347
+ artifact=_TRANSACTION_ARTIFACT,
1348
+ validate_cross_fields=_transaction_errors,
1349
+ )
1350
+ except JsonBoundaryError as exc:
1351
+ raise UserResponseError(str(exc)) from exc
1352
+ anchor = payload["anchor"]
1353
+ if _canonical_anchor_digest(anchor) != expected_anchor_digest:
1354
+ raise UserResponseError("transaction anchor digest does not match its token")
1355
+ if payload["transactionId"] != transaction_id:
1356
+ raise UserResponseError("transaction id does not match its state")
1357
+ return path, payload
1358
+
1359
+
1360
+ def _write_transaction(path: Path, payload: Mapping[str, Any]) -> None:
1361
+ try:
1362
+ write_owned_object_atomic(
1363
+ path,
1364
+ payload,
1365
+ artifact=_TRANSACTION_ARTIFACT,
1366
+ validate_cross_fields=_transaction_errors,
1367
+ )
1368
+ except JsonBoundaryError as exc:
1369
+ raise UserResponseError(str(exc)) from exc
1370
+
1371
+
1372
+ def _context_from_transaction(
1373
+ path: Path, payload: Mapping[str, Any]
1374
+ ) -> ResponseReportContext:
1375
+ anchor = payload["anchor"]
1376
+ context = _validate_owned_report_context(
1377
+ Path(str(anchor["reportPath"])),
1378
+ expected_task_key=str(anchor["taskKey"]),
1379
+ )
1380
+ expected_path = (
1381
+ context.run_root
1382
+ / "state"
1383
+ / "user-response-transactions"
1384
+ / path.parent.name
1385
+ / "draft.json"
1386
+ )
1387
+ comparisons = {
1388
+ "nonce": path.parent.name,
1389
+ "reportContractVersion": context.report_contract_version,
1390
+ "taskType": context.task_type,
1391
+ "taskKey": context.task_key,
1392
+ "runSeq": context.seq,
1393
+ "sourceReport": context.source_report,
1394
+ "sourceData": context.source_data,
1395
+ "sourceDataSha256": context.source_data_sha256,
1396
+ "reportPath": str(context.report_path),
1397
+ "sidecarPath": str(context.sidecar_path),
1398
+ "transactionPath": str(expected_path),
1399
+ }
1400
+ for field, expected in comparisons.items():
1401
+ if str(anchor.get(field) or "") != expected:
1402
+ raise UserResponseError(f"transaction {field} no longer matches report context")
1403
+ if path != expected_path:
1404
+ raise UserResponseError("transaction canonical path no longer matches report context")
1405
+ return context
1406
+
1407
+
1408
+ def _sidecar_snapshot(path: Path) -> dict[str, Any]:
1409
+ if not path.exists() and not path.is_symlink():
1410
+ return {"exists": False, "sha256": None}
1411
+ if not path.is_file() or path.is_symlink():
1412
+ raise UserResponseError(f"sidecar must be a regular file: {path}")
1413
+ return {"exists": True, "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
1414
+
1415
+
1416
+ def _validate_draft_against_context(
1417
+ draft: Mapping[str, Any], context: ResponseReportContext
1418
+ ) -> None:
1419
+ rows, _ = _all_report_rows(context.report_path)
1420
+ rows_by_id = {row["item"].row_id: row for row in rows}
1421
+ seen: set[str] = set()
1422
+ for answer in draft["answers"]:
1423
+ clarification_id = str(answer["id"])
1424
+ if clarification_id in seen:
1425
+ raise UserResponseError(
1426
+ f"duplicate clarification id in transaction draft: {clarification_id}"
1427
+ )
1428
+ seen.add(clarification_id)
1429
+ row = rows_by_id.get(clarification_id)
1430
+ if row is None:
1431
+ raise UserResponseError(
1432
+ f"clarification id does not exist in report: {clarification_id}"
1433
+ )
1434
+ if row["item"].status not in {"open", "answered"}:
1435
+ raise UserResponseError(
1436
+ f"clarification is resolved or closed: {clarification_id}"
1437
+ )
1438
+ if answer["kind"] != row["item"].kind:
1439
+ raise UserResponseError(
1440
+ f"clarification kind does not match {clarification_id}"
1441
+ )
1442
+ if answer["mode"] == "option":
1443
+ number = int(answer["optionNumber"])
1444
+ if number > len(row["options"]):
1445
+ raise UserResponseError(
1446
+ f"option number does not exist for {clarification_id}: {number}"
1447
+ )
1448
+ decision = draft.get("planDecision")
1449
+ if isinstance(decision, Mapping):
1450
+ candidates, _ = _implementation_option_projection(context)
1451
+ selected = str(decision.get("implementationOption") or "")
1452
+ if selected and selected not in candidates:
1453
+ raise UserResponseError(
1454
+ f"implementation option is not a report candidate: {selected}"
1455
+ )
1456
+ authoring = draft.get("legacyReportAuthoring")
1457
+ if context.report_contract_version != "2.0" and authoring is not None:
1458
+ raise UserResponseError(
1459
+ "legacy report authoring is allowed only for report contract 2.0"
1460
+ )
1461
+
1462
+
1463
+ @contextmanager
1464
+ def _locked_transaction(
1465
+ transaction_id: str, *, mutable: bool = False
1466
+ ) -> Iterator[tuple[Path, dict[str, Any], ResponseReportContext]]:
1467
+ decoded = _transaction_path(transaction_id)
1468
+ preflight_path, preflight_payload = _load_transaction(transaction_id)
1469
+ preflight_context = _context_from_transaction(preflight_path, preflight_payload)
1470
+ state_dir = preflight_context.run_root / "state"
1471
+ for candidate in (
1472
+ state_dir,
1473
+ decoded.parent.parent,
1474
+ decoded.parent,
1475
+ decoded,
1476
+ ):
1477
+ if candidate.is_symlink():
1478
+ raise UserResponseError(
1479
+ f"transaction state path contains a symlink: {candidate}"
1480
+ )
1481
+ with dir_flock(state_dir, ".user-response-transactions.lock"):
1482
+ path, payload = _load_transaction(transaction_id)
1483
+ context = _context_from_transaction(path, payload)
1484
+ _validate_draft_against_context(payload["draft"], context)
1485
+ if mutable and payload["publication"]["status"] != "draft":
1486
+ raise UserResponseError("user-response transaction publication has already begun")
1487
+ yield path, payload, context
1488
+
1489
+
1490
+ def begin_response(report_path: Path, task_key: str) -> str:
1491
+ context = _validate_owned_report_context(report_path, expected_task_key=task_key)
1492
+ transaction_dir = (
1493
+ context.report_path.parent.parent
1494
+ / "state"
1495
+ / "user-response-transactions"
1496
+ / uuid.uuid4().hex
1497
+ )
1498
+ path = transaction_dir / "draft.json"
1499
+ created_at = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1500
+ state_dir = context.run_root / "state"
1501
+ if state_dir.is_symlink():
1502
+ raise UserResponseError(f"transaction state path contains a symlink: {state_dir}")
1503
+ with dir_flock(state_dir, ".user-response-transactions.lock"):
1504
+ anchor = {
1505
+ "nonce": path.parent.name,
1506
+ "transactionPath": str(path),
1507
+ "reportPath": str(context.report_path),
1508
+ "reportContractVersion": context.report_contract_version,
1509
+ "taskKey": context.task_key,
1510
+ "taskType": context.task_type,
1511
+ "runSeq": context.seq,
1512
+ "sourceReport": context.source_report,
1513
+ "sourceData": context.source_data,
1514
+ "sourceDataSha256": context.source_data_sha256,
1515
+ "sidecarPath": str(context.sidecar_path),
1516
+ "baseSidecar": _sidecar_snapshot(context.sidecar_path),
1517
+ "createdAt": created_at,
1518
+ }
1519
+ transaction_id = _encode_transaction_path(path, anchor)
1520
+ payload = {
1521
+ "schemaVersion": "2.0",
1522
+ "transactionId": transaction_id,
1523
+ "anchor": anchor,
1524
+ "draft": {
1525
+ "answers": [],
1526
+ "planDecision": None,
1527
+ "legacyReportAuthoring": None,
1528
+ },
1529
+ "publication": {
1530
+ "status": "draft",
1531
+ "intentSha256": None,
1532
+ "finalizedSha256": None,
1533
+ },
1534
+ }
1535
+ _write_transaction(path, payload)
1536
+ return transaction_id
1537
+
1538
+
1539
+ def _read_body_file(
1540
+ path: Path,
1541
+ field: str,
1542
+ context: ResponseReportContext,
1543
+ *,
1544
+ required: bool = True,
1545
+ ) -> str:
1546
+ lexical = _lexical_absolute(path)
1547
+ temp_root = context.project_root / ".okstra/tmp/user-response"
1548
+ try:
1549
+ relative = lexical.relative_to(temp_root)
1550
+ except ValueError as exc:
1551
+ raise UserResponseError(f"{field} file must stay under {temp_root}") from exc
1552
+ if resolve_under_root(temp_root, relative.as_posix()) is None:
1553
+ raise UserResponseError(f"{field} file must stay under {temp_root}")
1554
+ _reject_symlinks_below(context.project_root, lexical, f"{field} file")
1555
+ if not lexical.is_file() or lexical.is_symlink():
1556
+ raise UserResponseError(f"{field} file must be a regular file: {lexical}")
1557
+ try:
1558
+ value = lexical.read_text(encoding="utf-8").strip()
1559
+ except (OSError, UnicodeError, ValueError) as exc:
1560
+ raise UserResponseError(f"{field} file is unreadable: {lexical}") from exc
1561
+ if required and not value:
1562
+ raise UserResponseError(f"{field} file must not be empty")
1563
+ return value
1564
+
1565
+
1566
+ def answer_response(
1567
+ transaction_id: str,
1568
+ clarification_id: str,
1569
+ kind: str,
1570
+ disposition: str | None,
1571
+ value_file: Path | None,
1572
+ rationale_file: Path | None,
1573
+ option_number: int | None = None,
1574
+ ) -> None:
1575
+ option_mode = option_number is not None
1576
+ if option_mode and any((disposition, value_file, rationale_file)):
1577
+ raise UserResponseError(
1578
+ "--option-number cannot be combined with direct answer fields"
1579
+ )
1580
+ if not option_mode and (not disposition or value_file is None):
1581
+ raise UserResponseError(
1582
+ "direct answers require --disposition and --value-file"
1583
+ )
1584
+ with _locked_transaction(transaction_id, mutable=True) as (path, payload, context):
1585
+ rows, _ = _all_report_rows(context.report_path)
1586
+ matching = [row for row in rows if row["item"].row_id == clarification_id]
1587
+ if len(matching) != 1:
1588
+ raise UserResponseError(
1589
+ f"clarification id does not exist in report: {clarification_id}"
1590
+ )
1591
+ row = matching[0]
1592
+ if row["item"].status not in {"open", "answered"}:
1593
+ raise UserResponseError(
1594
+ f"clarification is resolved or closed: {clarification_id}"
1595
+ )
1596
+ if row["item"].kind != kind:
1597
+ raise UserResponseError(f"clarification kind does not match {clarification_id}")
1598
+ if option_mode:
1599
+ options = row["options"]
1600
+ if option_number is None or option_number < 1 or option_number > len(options):
1601
+ raise UserResponseError(
1602
+ f"option number does not exist for {clarification_id}: {option_number}"
1603
+ )
1604
+ answer = {
1605
+ "mode": "option",
1606
+ "id": clarification_id,
1607
+ "kind": kind,
1608
+ "optionNumber": option_number,
1609
+ }
1610
+ else:
1611
+ if disposition not in _DIRECT_ANSWER_DISPOSITIONS:
1612
+ raise UserResponseError(f"invalid answer disposition: {disposition}")
1613
+ answer = {
1614
+ "mode": "direct",
1615
+ "id": clarification_id,
1616
+ "kind": kind,
1617
+ "value": _read_body_file(value_file, "answer", context),
1618
+ "rationale": (
1619
+ " ".join(
1620
+ _read_body_file(rationale_file, "rationale", context).splitlines()
1621
+ )
1622
+ if rationale_file
1623
+ else ""
1624
+ ),
1625
+ "disposition": disposition,
1626
+ }
1627
+ answers = [
1628
+ existing for existing in payload["draft"]["answers"]
1629
+ if existing.get("id") != clarification_id
1630
+ ]
1631
+ payload["draft"]["answers"] = [*answers, answer]
1632
+ _write_transaction(path, payload)
1633
+
1634
+
1635
+ def set_plan_decision(
1636
+ transaction_id: str,
1637
+ status: str,
1638
+ implementation_option: str,
1639
+ reason_file: Path | None,
1640
+ ) -> None:
1641
+ with _locked_transaction(transaction_id, mutable=True) as (path, payload, context):
1642
+ if status not in _PLAN_DECISION_STATUSES:
1643
+ raise UserResponseError(f"invalid plan decision status: {status}")
1644
+ reason = (
1645
+ _read_body_file(reason_file, "plan decision reason", context)
1646
+ if reason_file else ""
1647
+ )
1648
+ if status != "approved" and not reason:
1649
+ raise UserResponseError(f"plan decision {status} requires --reason-file")
1650
+ selected = implementation_option.strip()
1651
+ candidates, _ = _implementation_option_projection(context)
1652
+ if selected and selected not in candidates:
1653
+ raise UserResponseError(
1654
+ f"implementation option is not a report candidate: {selected}"
1655
+ )
1656
+ payload["draft"]["planDecision"] = {
1657
+ "status": status,
1658
+ "implementationOption": selected,
1659
+ "reason": reason,
1660
+ }
1661
+ _write_transaction(path, payload)
1662
+
1663
+
1664
+ def set_legacy_report_authoring(
1665
+ transaction_id: str, status: str, reason_file: Path
1666
+ ) -> None:
1667
+ with _locked_transaction(transaction_id, mutable=True) as (path, payload, context):
1668
+ if context.report_contract_version != "2.0":
1669
+ raise UserResponseError(
1670
+ "legacy report authoring is allowed only for report contract 2.0"
1671
+ )
1672
+ if status not in {"approved", "denied"}:
1673
+ raise UserResponseError(f"invalid legacy report authoring status: {status}")
1674
+ payload["draft"]["legacyReportAuthoring"] = {
1675
+ "status": status,
1676
+ "reason": _read_body_file(
1677
+ reason_file, "legacy report authoring reason", context
1678
+ ),
1679
+ }
1680
+ _write_transaction(path, payload)
1681
+
1682
+
1683
+ def _existing_sidecar_state(sidecar: Path) -> ExistingSidecarState:
1684
+ if not sidecar.exists():
1685
+ return ExistingSidecarState(
1686
+ entries=(),
1687
+ plan_decision=None,
1688
+ analysis_review=None,
1689
+ direction_selection=None,
1690
+ report_authoring=None,
1691
+ run_meta=None,
1692
+ created_at="",
1693
+ )
1694
+ if not sidecar.is_file() or sidecar.is_symlink():
1695
+ raise UserResponseError(f"sidecar must be a regular file: {sidecar}")
1696
+ text = sidecar.read_text(encoding="utf-8")
1697
+ plan_record = parse_plan_decision(text)
1698
+ review_record = parse_analysis_review(text)
1699
+ direction_record = parse_direction_selection(text)
1700
+ return ExistingSidecarState(
1701
+ entries=tuple(parse_user_response_entries(text)),
1702
+ plan_decision=(
1703
+ UserPlanDecision(
1704
+ plan_record.status,
1705
+ plan_record.implementation_option,
1706
+ plan_record.reason,
1707
+ )
1708
+ if plan_record
1709
+ else None
1710
+ ),
1711
+ analysis_review=(
1712
+ UserResponseAnalysisReview(
1713
+ review_record.status,
1714
+ review_record.affected_ids,
1715
+ review_record.reason,
1716
+ review_record.additional_evidence,
1717
+ review_record.requested_scope_change,
1718
+ )
1719
+ if review_record
1720
+ else None
1721
+ ),
1722
+ direction_selection=(
1723
+ UserDirectionSelection(
1724
+ direction_record.option_id,
1725
+ direction_record.option_name,
1726
+ direction_record.confirmed,
1727
+ direction_record.selection_note,
1728
+ direction_record.constraints,
1729
+ )
1730
+ if direction_record
1731
+ else None
1732
+ ),
1733
+ report_authoring=parse_report_authoring(text),
1734
+ run_meta=RunMeta(
1735
+ task_key=_sidecar_metadata_value(text, "task-key"),
1736
+ task_type=_sidecar_metadata_value(text, "task-type"),
1737
+ seq=_sidecar_metadata_value(text, "seq"),
1738
+ source_report=_sidecar_metadata_value(text, "source-report"),
1739
+ source_data=_sidecar_metadata_value(text, "source-data"),
1740
+ source_data_sha256=_sidecar_metadata_value(
1741
+ text, "source-data-sha256"
1742
+ ),
1743
+ ),
1744
+ created_at=_sidecar_metadata_value(text, "created-at"),
1745
+ )
1746
+
1747
+
1748
+ def _lossless_existing_sidecar_state(sidecar: Path) -> tuple[
1749
+ str,
1750
+ ExistingSidecarState,
1751
+ ]:
1752
+ text = sidecar.read_text(encoding="utf-8")
1753
+ known_blocks = {
1754
+ "PLAN DECISION",
1755
+ "ANALYSIS REVIEW",
1756
+ "DIRECTION SELECTION",
1757
+ "REPORT AUTHORING",
1758
+ }
1759
+ seen: set[str] = set()
1760
+ for match in re.finditer(r"^## (?P<title>[^\r\n]+)\s*$", text, re.MULTILINE):
1761
+ title = match.group("title").strip()
1762
+ if title not in known_blocks and re.fullmatch(
1763
+ r"[A-Za-z][A-Za-z0-9]*-\d+", title
1764
+ ) is None:
1765
+ raise UserResponseError(
1766
+ "existing sidecar cannot be losslessly updated; bytes preserved "
1767
+ f"(unknown block: {title})"
1768
+ )
1769
+ if title in seen:
1770
+ raise UserResponseError(
1771
+ "existing sidecar cannot be losslessly updated; bytes preserved "
1772
+ f"(duplicate block: {title})"
1773
+ )
1774
+ seen.add(title)
1775
+ try:
1776
+ state = _existing_sidecar_state(sidecar)
1777
+ if state.run_meta is None:
1778
+ raise UserResponseError("existing sidecar provenance is missing")
1779
+ canonical = serialize_user_response(
1780
+ run_meta=state.run_meta,
1781
+ entries=list(state.entries),
1782
+ created_at=state.created_at,
1783
+ plan_decision=state.plan_decision,
1784
+ analysis_review=state.analysis_review,
1785
+ direction_selection=state.direction_selection,
1786
+ report_authoring=state.report_authoring,
1787
+ )
1788
+ except (UserResponseError, ValueError) as exc:
1789
+ raise UserResponseError(
1790
+ "existing sidecar cannot be losslessly updated; bytes preserved "
1791
+ f"({exc})"
1792
+ ) from exc
1793
+ if canonical != text:
1794
+ raise UserResponseError(
1795
+ "existing sidecar cannot be losslessly updated; bytes preserved"
1796
+ )
1797
+ return text, state
1798
+
1799
+
1800
+ def _atomic_write_sidecar(path: Path, body: str) -> None:
1801
+ path.parent.mkdir(parents=True, exist_ok=True)
1802
+ if path.parent.is_symlink():
1803
+ raise UserResponseError(f"sidecar directory must not be a symlink: {path.parent}")
1804
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
1805
+ temporary = Path(temporary_name)
1806
+ try:
1807
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
1808
+ handle.write(body)
1809
+ handle.flush()
1810
+ os.fsync(handle.fileno())
1811
+ os.replace(temporary, path)
1812
+ except OSError as exc:
1813
+ raise UserResponseError(f"could not publish sidecar: {path}") from exc
1814
+ finally:
1815
+ temporary.unlink(missing_ok=True)
1816
+
1817
+
1818
+ def _transaction_decision(payload: Mapping[str, Any]) -> UserPlanDecision | None:
1819
+ decision = payload.get("planDecision")
1820
+ if not isinstance(decision, Mapping):
1821
+ return None
1822
+ return UserPlanDecision(
1823
+ status=str(decision.get("status") or ""),
1824
+ implementation_option=str(decision.get("implementationOption") or ""),
1825
+ reason=str(decision.get("reason") or ""),
1826
+ )
1827
+
1828
+
1829
+ def _transaction_authoring(payload: Mapping[str, Any]) -> UserReportAuthoring | None:
1830
+ authoring = payload.get("legacyReportAuthoring")
1831
+ if not isinstance(authoring, Mapping):
1832
+ return None
1833
+ return UserReportAuthoring(
1834
+ status=str(authoring.get("status") or ""),
1835
+ reason=str(authoring.get("reason") or ""),
1836
+ )
1837
+
1838
+
1839
+ def _render_transaction_sidecar(
1840
+ payload: Mapping[str, Any], context: ResponseReportContext
1841
+ ) -> str:
1842
+ anchor = payload["anchor"]
1843
+ draft = payload["draft"]
1844
+ sidecar = context.sidecar_path
1845
+ existing_text = ""
1846
+ if sidecar.exists():
1847
+ existing_text, existing_state = _lossless_existing_sidecar_state(sidecar)
1848
+ else:
1849
+ existing_state = _existing_sidecar_state(sidecar)
1850
+ if (
1851
+ existing_text
1852
+ and not draft["answers"]
1853
+ and draft["planDecision"] is None
1854
+ and draft["legacyReportAuthoring"] is None
1855
+ ):
1856
+ return existing_text
1857
+ merged = {entry.response_id: entry for entry in existing_state.entries}
1858
+ report_rows, _ = _all_report_rows(context.report_path)
1859
+ rows_by_id = {row["item"].row_id: row for row in report_rows}
1860
+ for answer in draft["answers"]:
1861
+ if answer["mode"] == "option":
1862
+ option = rows_by_id[str(answer["id"])]["options"][answer["optionNumber"] - 1]
1863
+ value = str(option.get("answer") or "")
1864
+ rationale = str(option.get("rationale") or "")
1865
+ disposition = str(option.get("disposition") or "answer")
1866
+ else:
1867
+ value = str(answer["value"])
1868
+ rationale = str(answer.get("rationale") or "")
1869
+ disposition = str(answer["disposition"])
1870
+ merged[str(answer["id"])] = UserResponseEntry(
1871
+ response_id=str(answer["id"]),
1872
+ kind=str(answer["kind"]),
1873
+ value=value,
1874
+ rationale=rationale or None,
1875
+ disposition=disposition,
1876
+ )
1877
+ if existing_state.run_meta is not None:
1878
+ run_meta = existing_state.run_meta
1879
+ created_at = existing_state.created_at
1880
+ else:
1881
+ run_meta = RunMeta(
1882
+ task_key=context.task_key,
1883
+ task_type=context.task_type,
1884
+ seq=context.seq,
1885
+ source_report=context.source_report,
1886
+ source_data=context.source_data,
1887
+ source_data_sha256=context.source_data_sha256,
1888
+ )
1889
+ created_at = str(anchor["createdAt"])
1890
+ return serialize_user_response(
1891
+ run_meta=run_meta,
1892
+ entries=list(merged.values()),
1893
+ created_at=created_at,
1894
+ plan_decision=(
1895
+ _transaction_decision(draft) or existing_state.plan_decision
1896
+ ),
1897
+ analysis_review=existing_state.analysis_review,
1898
+ direction_selection=existing_state.direction_selection,
1899
+ report_authoring=(
1900
+ _transaction_authoring(draft) or existing_state.report_authoring
1901
+ ),
1902
+ )
1903
+
1904
+
1905
+ def finalize_response(transaction_id: str) -> Path:
1906
+ with _locked_transaction(transaction_id) as (path, payload, context):
1907
+ sidecar = context.sidecar_path
1908
+ actual = _sidecar_snapshot(sidecar)
1909
+ publication = payload["publication"]
1910
+ status_value = publication["status"]
1911
+ finalized_digest = publication["finalizedSha256"]
1912
+ if status_value == "finalized":
1913
+ if (
1914
+ actual["exists"] is not True
1915
+ or not _valid_sha256(finalized_digest)
1916
+ or actual["sha256"] != finalized_digest
1917
+ ):
1918
+ raise UserResponseError(
1919
+ "finalized transaction sidecar digest no longer matches"
1920
+ )
1921
+ return sidecar
1922
+ base = payload["anchor"]["baseSidecar"]
1923
+ intent_digest = publication["intentSha256"]
1924
+ if (
1925
+ status_value == "intent-recorded"
1926
+ and _valid_sha256(intent_digest)
1927
+ and actual["exists"] is True
1928
+ and actual["sha256"] == intent_digest
1929
+ ):
1930
+ publication["status"] = "finalized"
1931
+ publication["finalizedSha256"] = intent_digest
1932
+ _write_transaction(path, payload)
1933
+ return sidecar
1934
+ if actual != base:
1935
+ raise UserResponseError(
1936
+ "stale user-response transaction: sidecar changed after begin"
1937
+ )
1938
+ body = _render_transaction_sidecar(payload, context)
1939
+ rendered_digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
1940
+ if status_value == "intent-recorded" and intent_digest != rendered_digest:
1941
+ raise UserResponseError("transaction publish intent no longer matches draft")
1942
+ if status_value == "draft":
1943
+ publication["status"] = "intent-recorded"
1944
+ publication["intentSha256"] = rendered_digest
1945
+ _write_transaction(path, payload)
1946
+ if not (
1947
+ actual["exists"] is True
1948
+ and actual["sha256"] == rendered_digest
1949
+ ):
1950
+ _atomic_write_sidecar(sidecar, body)
1951
+ publication["status"] = "finalized"
1952
+ publication["finalizedSha256"] = rendered_digest
1953
+ _write_transaction(path, payload)
1954
+ return sidecar
1955
+
1956
+
1957
+ def format_list_view(rows: list[dict[str, Any]]) -> str:
1958
+ lines = ["USER RESPONSE TASKS", f"Count: {len(rows)}"]
1959
+ for index, row in enumerate(rows, start=1):
1960
+ status = "unreadable" if row.get("unreadable") else "ready"
1961
+ lines.extend([
1962
+ "",
1963
+ f"[{index}]",
1964
+ f"Task key: {row.get('canonicalTaskKey', row.get('taskKey', ''))}",
1965
+ f"Task type: {row.get('taskType', '')}",
1966
+ f"Run sequence: {row.get('seq', '')}",
1967
+ f"Report: {row.get('normalizedReportPath', row.get('reportPath', ''))}",
1968
+ f"Open items: {row.get('openBlockerCount', 0)}",
1969
+ f"Open approval items: {row.get('openApprovalCount', 0)}",
1970
+ f"Plan decision required: {'yes' if row.get('planDecisionRequired') else 'no'}",
1971
+ f"Status: {status}",
1972
+ ])
1973
+ return "\n".join(lines) + "\n"
1974
+
1975
+
1976
+ def _option_view(option: Mapping[str, Any], index: int) -> list[str]:
1977
+ scope = option.get("scopeImpact")
1978
+ scope_text = ", ".join(str(item) for item in scope) if isinstance(scope, list) else ""
1979
+ effects = option.get("scopeEffects")
1980
+ effects_text = ", ".join(str(item) for item in effects) if isinstance(effects, list) else ""
1981
+ return [
1982
+ f"Option {index}:",
1983
+ f" Role: {option.get('role', '')}",
1984
+ f" Answer: {option.get('answer', '')}",
1985
+ f" Rationale: {option.get('rationale', '')}",
1986
+ f" Scope impact: {scope_text or 'not stated in the report'}",
1987
+ f" Added work: {option.get('addedWork') or 'not stated in the report'}",
1988
+ f" Direction change: {option.get('directionChange') or 'not stated in the report'}",
1989
+ f" Disposition: {option.get('disposition') or 'answer'}",
1990
+ f" Reach: {option.get('reach') or 'not stated in the report'}",
1991
+ f" Scope effects: {effects_text or 'not stated in the report'}",
1992
+ ]
1993
+
1994
+
1995
+ def format_show_view(report_path: Path, project_root: Path) -> str:
1996
+ context = _validate_owned_report_context(
1997
+ report_path, expected_project_root=project_root
1998
+ )
1999
+ rows, record = _all_report_rows(context.report_path)
2000
+ state = _existing_sidecar_state(context.sidecar_path)
2001
+ current = {entry.response_id: entry for entry in state.entries}
2002
+ lines = [
2003
+ "USER RESPONSE REPORT",
2004
+ f"Report: {context.report_path}",
2005
+ f"Task key: {context.task_key}",
2006
+ f"Task type: {context.task_type}",
2007
+ f"Report contract: {context.report_contract_version}",
2008
+ f"Clarification items: {len(rows)}",
2009
+ "Current plan decision: "
2010
+ f"{state.plan_decision.status if state.plan_decision else 'none'}",
2011
+ "Current legacy report authoring: "
2012
+ f"{state.report_authoring.status if state.report_authoring else 'none'}",
2013
+ ]
2014
+ candidates, recommended_name = _implementation_option_projection(context)
2015
+ if candidates:
2016
+ lines.append("Plan option candidates:")
2017
+ for index, candidate in enumerate(candidates, start=1):
2018
+ lines.extend([
2019
+ f"Plan option {index}: {candidate}",
2020
+ f" Recommended: {'yes' if candidate == recommended_name else 'no'}",
2021
+ " Current decision: "
2022
+ f"{'yes' if state.plan_decision and state.plan_decision.implementation_option == candidate else 'no'}",
2023
+ ])
2024
+ for row in rows:
2025
+ item = row["item"]
2026
+ if item.status not in {"open", "answered"} or item.row_id in current:
2027
+ continue
2028
+ refs = sorted(set(_SECTION_REF_RE.findall(
2029
+ row["statement"] + " " + row["expected_form"]
2030
+ )))
2031
+ resolved = (
2032
+ resolve_refs_from_record(record, refs)
2033
+ if record is not None
2034
+ else resolve_refs(context.markdown_path.read_text(encoding="utf-8"), refs)
2035
+ )
2036
+ response = current.get(item.row_id)
2037
+ lines.extend([
2038
+ "",
2039
+ f"[{item.row_id}]",
2040
+ f"Kind: {item.kind}",
2041
+ f"Blocks: {item.blocks}",
2042
+ f"Report status: {item.status}",
2043
+ f"Question: {row['statement']}",
2044
+ f"Expected form: {row['expected_form']}",
2045
+ f"Current response: {response.value if response else 'none'}",
2046
+ f"Current disposition: {response.disposition if response else 'none'}",
2047
+ "Options:",
2048
+ ])
2049
+ approval = row.get("approval_context") or {}
2050
+ if approval:
2051
+ lines.extend([
2052
+ f"Approval classification: {approval.get('classification', '')}",
2053
+ f"Approval unblock condition: {approval.get('unblockCondition', '')}",
2054
+ f"Approval recommended disposition: {approval.get('recommendedDisposition', '')}",
2055
+ ])
2056
+ for index, option in enumerate(row["options"], start=1):
2057
+ lines.extend(_option_view(option, index))
2058
+ lines.append("Context:")
2059
+ lines.extend(
2060
+ f"- {ref['ref']}: {ref['definition'] or 'not stated in the report'}"
2061
+ for ref in resolved
2062
+ )
2063
+ return "\n".join(lines) + "\n"
754
2064
 
755
2065
 
756
2066
  def write_sidecar(report_path: Path, answers: list[dict],
@@ -790,51 +2100,106 @@ def write_sidecar(report_path: Path, answers: list[dict],
790
2100
  return sidecar
791
2101
 
792
2102
 
793
- def main(argv: Optional[list[str]] = None) -> int:
2103
+ def _add_list_arguments(parser: argparse.ArgumentParser) -> None:
2104
+ parser.add_argument("--home", required=True)
2105
+ parser.add_argument("--project", required=True)
2106
+ parser.add_argument("--limit", type=int, default=3)
2107
+
2108
+
2109
+ def _build_parser() -> argparse.ArgumentParser:
794
2110
  parser = argparse.ArgumentParser(prog="okstra user-response")
795
2111
  sub = parser.add_subparsers(dest="cmd", required=True)
2112
+ _add_list_arguments(sub.add_parser("list"))
2113
+ _add_list_arguments(sub.add_parser("list-view"))
2114
+ sub.add_parser("show").add_argument("--report", required=True)
2115
+ show_view = sub.add_parser("show-view")
2116
+ show_view.add_argument("--report", required=True)
2117
+ show_view.add_argument("--project-root", required=True)
2118
+ begin = sub.add_parser("begin")
2119
+ begin.add_argument("--report", required=True)
2120
+ begin.add_argument("--task-key", required=True)
2121
+ answer = sub.add_parser("answer")
2122
+ answer.add_argument("--transaction", required=True)
2123
+ answer.add_argument("--clarification-id", required=True)
2124
+ answer.add_argument("--kind", required=True)
2125
+ answer.add_argument("--disposition", choices=sorted(_DIRECT_ANSWER_DISPOSITIONS))
2126
+ answer_input = answer.add_mutually_exclusive_group(required=True)
2127
+ answer_input.add_argument("--option-number", type=int)
2128
+ answer_input.add_argument("--value-file")
2129
+ answer.add_argument("--rationale-file")
2130
+ decision = sub.add_parser("plan-decision")
2131
+ decision.add_argument("--transaction", required=True)
2132
+ decision.add_argument("--status", choices=sorted(_PLAN_DECISION_STATUSES), required=True)
2133
+ decision.add_argument("--implementation-option", default="")
2134
+ decision.add_argument("--reason-file")
2135
+ authoring = sub.add_parser("legacy-report-authoring")
2136
+ authoring.add_argument("--transaction", required=True)
2137
+ authoring.add_argument("--status", choices=("approved", "denied"), required=True)
2138
+ authoring.add_argument("--reason-file", required=True)
2139
+ sub.add_parser("finalize").add_argument("--transaction", required=True)
2140
+ return parser
2141
+
2142
+
2143
+ def _write_json(value: object) -> None:
2144
+ json.dump(value, sys.stdout, ensure_ascii=False)
2145
+
2146
+
2147
+ def _dispatch_command(ns: argparse.Namespace) -> None:
2148
+ if ns.cmd in {"list", "list-view"}:
2149
+ rows = list_awaiting_tasks(Path(ns.home), ns.project, ns.limit)
2150
+ if ns.cmd == "list":
2151
+ _write_json(rows)
2152
+ else:
2153
+ sys.stdout.write(format_list_view(rows))
2154
+ elif ns.cmd == "show":
2155
+ _write_json(show_open_rows(Path(ns.report)))
2156
+ elif ns.cmd == "show-view":
2157
+ sys.stdout.write(format_show_view(Path(ns.report), Path(ns.project_root)))
2158
+ elif ns.cmd == "begin":
2159
+ _write_json({"transaction": begin_response(Path(ns.report), ns.task_key)})
2160
+ elif ns.cmd == "answer":
2161
+ answer_response(
2162
+ ns.transaction,
2163
+ ns.clarification_id,
2164
+ ns.kind,
2165
+ ns.disposition,
2166
+ Path(ns.value_file) if ns.value_file else None,
2167
+ Path(ns.rationale_file) if ns.rationale_file else None,
2168
+ ns.option_number,
2169
+ )
2170
+ _write_json({"transaction": ns.transaction, "status": "draft"})
2171
+ elif ns.cmd == "plan-decision":
2172
+ set_plan_decision(
2173
+ ns.transaction,
2174
+ ns.status,
2175
+ ns.implementation_option,
2176
+ Path(ns.reason_file) if ns.reason_file else None,
2177
+ )
2178
+ _write_json({"transaction": ns.transaction, "status": "draft"})
2179
+ elif ns.cmd == "legacy-report-authoring":
2180
+ set_legacy_report_authoring(
2181
+ ns.transaction, ns.status, Path(ns.reason_file)
2182
+ )
2183
+ _write_json({"transaction": ns.transaction, "status": "draft"})
2184
+ elif ns.cmd == "finalize":
2185
+ _write_json({"sidecar": str(finalize_response(ns.transaction))})
796
2186
 
797
- pl = sub.add_parser("list")
798
- pl.add_argument("--home", required=True)
799
- pl.add_argument("--project", required=True)
800
- pl.add_argument("--limit", type=int, default=3)
801
-
802
- ps = sub.add_parser("show")
803
- ps.add_argument("--report", required=True)
804
-
805
- pw = sub.add_parser("write")
806
- pw.add_argument("--report", required=True)
807
- pw.add_argument("--answers", required=True, help="JSON array of answer entries")
808
- pw.add_argument(
809
- "--plan-decision", default="",
810
- help='JSON plan decision, e.g. {"status":"rejected","reason":"..."}')
811
- pw.add_argument(
812
- "--report-authoring", default="",
813
- help='JSON report-authoring permission, e.g. '
814
- '{"status":"approved","reason":"report-writer failed twice"}. '
815
- 'Only the user may grant this; the lead cannot write it for itself.')
816
- pw.add_argument("--task-key", default="", help="task-key from list/show context")
817
-
818
- ns = parser.parse_args(argv)
819
- if ns.cmd == "list":
820
- out = list_awaiting_tasks(Path(ns.home), ns.project, ns.limit)
821
- json.dump(out, sys.stdout, ensure_ascii=False)
822
- return 0
823
- if ns.cmd == "show":
824
- json.dump(show_open_rows(Path(ns.report)), sys.stdout, ensure_ascii=False)
825
- return 0
826
- if ns.cmd == "write":
827
- answers = json.loads(ns.answers)
828
- decision = json.loads(ns.plan_decision) if ns.plan_decision else None
829
- authoring = (
830
- json.loads(ns.report_authoring) if ns.report_authoring else None
831
- )
832
- created_at = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
833
- p = write_sidecar(Path(ns.report), answers, decision, created_at,
834
- task_key=ns.task_key, report_authoring=authoring)
835
- json.dump({"sidecar": str(p)}, sys.stdout, ensure_ascii=False)
836
- return 0
837
- return 1
2187
+
2188
+ def main(argv: Optional[list[str]] = None) -> int:
2189
+ parser = _build_parser()
2190
+ namespace = parser.parse_args(argv)
2191
+ if namespace.cmd == "answer":
2192
+ option_mode = namespace.option_number is not None
2193
+ if option_mode and (
2194
+ namespace.disposition is not None or namespace.rationale_file is not None
2195
+ ):
2196
+ parser.error(
2197
+ "--option-number cannot be combined with --disposition or --rationale-file"
2198
+ )
2199
+ if not option_mode and namespace.disposition is None:
2200
+ parser.error("--value-file requires --disposition")
2201
+ _dispatch_command(namespace)
2202
+ return 0
838
2203
 
839
2204
 
840
2205
  if __name__ == "__main__":