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.
- package/README.md +1 -1
- package/dist/cli-registry.mjs +30 -2
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/incremental-carry.mjs +9 -8
- package/dist/commands/execute/incremental-carry.mjs.map +1 -1
- package/dist/commands/execute/plan-verify.mjs +3 -1
- package/dist/commands/execute/plan-verify.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.d.mts +4 -2
- package/dist/commands/execute/render-bundle.mjs +46 -5
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/execute/run.mjs +11 -3
- package/dist/commands/execute/run.mjs.map +1 -1
- package/dist/commands/inspect/model-io.d.mts +1 -0
- package/dist/commands/inspect/model-io.mjs +25 -0
- package/dist/commands/inspect/model-io.mjs.map +1 -0
- package/dist/commands/inspect/stage-map.mjs +29 -8
- package/dist/commands/inspect/stage-map.mjs.map +1 -1
- package/dist/commands/inspect/task-list.mjs +52 -6
- package/dist/commands/inspect/task-list.mjs.map +1 -1
- package/dist/commands/inspect/user-response.mjs +14 -4
- package/dist/commands/inspect/user-response.mjs.map +1 -1
- package/dist/commands/lifecycle/check-project.d.mts +1 -0
- package/dist/commands/lifecycle/check-project.mjs +69 -50
- package/dist/commands/lifecycle/check-project.mjs.map +1 -1
- package/dist/commands/lifecycle/contract-check.d.mts +1 -0
- package/dist/commands/lifecycle/contract-check.mjs +18 -0
- package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
- package/dist/commands/lifecycle/preflight.mjs +154 -51
- package/dist/commands/lifecycle/preflight.mjs.map +1 -1
- package/dist/commands/pr/pr.d.mts +1 -0
- package/dist/commands/pr/pr.mjs +19 -1
- package/dist/commands/pr/pr.mjs.map +1 -1
- package/dist/commands/report/agent-activity.mjs +2 -2
- package/dist/commands/report/approval-decision.d.mts +1 -0
- package/dist/commands/report/approval-decision.mjs +21 -0
- package/dist/commands/report/approval-decision.mjs.map +1 -0
- package/dist/commands/report/design-snapshot.d.mts +1 -0
- package/dist/commands/report/design-snapshot.mjs +19 -0
- package/dist/commands/report/design-snapshot.mjs.map +1 -0
- package/dist/commands/report/translate.mjs +3 -0
- package/dist/commands/report/translate.mjs.map +1 -1
- package/dist/lib/host-registry-client.mjs +13 -9
- package/dist/lib/host-registry-client.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +21 -10
- package/docs/cli.md +36 -20
- package/docs/container.md +6 -4
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
- package/docs/for-ai/skills/okstra-code-review.md +4 -4
- package/docs/for-ai/skills/okstra-container-build.md +20 -17
- package/docs/for-ai/skills/okstra-inspect.md +20 -23
- package/docs/for-ai/skills/okstra-manager.md +19 -18
- package/docs/for-ai/skills/okstra-memory.md +2 -2
- package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
- package/docs/for-ai/skills/okstra-rollup.md +14 -13
- package/docs/for-ai/skills/okstra-run.md +7 -3
- package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
- package/docs/for-ai/skills/okstra-setup.md +7 -7
- package/docs/for-ai/skills/okstra-usage.md +5 -4
- package/docs/for-ai/skills/okstra-user-response.md +50 -32
- package/docs/project-structure-overview.md +43 -31
- package/docs/task-process/implementation-planning.md +2 -2
- package/docs/task-process/implementation.md +7 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +4 -4
- package/runtime/agents/workers/report-writer-worker.md +15 -164
- package/runtime/agents/workers/translator-worker.md +5 -13
- package/runtime/bin/okstra-error-log.py +51 -11
- package/runtime/bin/okstra-report-translate.py +210 -23
- package/runtime/prompts/host-orchestration/implementation.md +1 -1
- package/runtime/prompts/launch.template.md +9 -12
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/context-loader.md +41 -141
- package/runtime/prompts/lead/convergence.md +10 -8
- package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
- package/runtime/prompts/lead/plan-body-verification.md +217 -23
- package/runtime/prompts/lead/report-writer.md +81 -421
- package/runtime/prompts/lead/team-contract.md +9 -54
- package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
- package/runtime/prompts/profiles/_common-contract.md +4 -4
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
- package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/feature-analysis.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +20 -12
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/project-analysis.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +0 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +0 -1
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +8 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
- package/runtime/python/okstra_ctl/agent_activity.py +141 -38
- package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
- package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
- package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
- package/runtime/python/okstra_ctl/backfill.py +13 -10
- package/runtime/python/okstra_ctl/batch.py +2 -4
- package/runtime/python/okstra_ctl/build_tools.py +6 -3
- package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
- package/runtime/python/okstra_ctl/clarification_items.py +27 -13
- package/runtime/python/okstra_ctl/cmux.py +130 -52
- package/runtime/python/okstra_ctl/code_review_target.py +34 -8
- package/runtime/python/okstra_ctl/consumers.py +5 -4
- package/runtime/python/okstra_ctl/container.py +103 -8
- package/runtime/python/okstra_ctl/context_cost.py +2 -1
- package/runtime/python/okstra_ctl/contract_graph.py +497 -0
- package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
- package/runtime/python/okstra_ctl/convergence.py +338 -17
- package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
- package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
- package/runtime/python/okstra_ctl/convergence_store.py +55 -34
- package/runtime/python/okstra_ctl/design_prep.py +7 -4
- package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
- package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
- package/runtime/python/okstra_ctl/doctor.py +6 -3
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
- package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
- package/runtime/python/okstra_ctl/error_log_write.py +35 -30
- package/runtime/python/okstra_ctl/error_report.py +26 -1
- package/runtime/python/okstra_ctl/error_zip.py +27 -5
- package/runtime/python/okstra_ctl/execution_identity.py +3 -2
- package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
- package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
- package/runtime/python/okstra_ctl/fixed_text.py +39 -0
- package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
- package/runtime/python/okstra_ctl/handoff.py +5 -4
- package/runtime/python/okstra_ctl/i18n.py +4 -2
- package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
- package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
- package/runtime/python/okstra_ctl/incremental_carry.py +130 -17
- package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
- package/runtime/python/okstra_ctl/index.py +8 -11
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
- package/runtime/python/okstra_ctl/invocation.py +3 -6
- package/runtime/python/okstra_ctl/json_boundary.py +366 -0
- package/runtime/python/okstra_ctl/json_registry.py +10 -12
- package/runtime/python/okstra_ctl/jsonl.py +19 -2
- package/runtime/python/okstra_ctl/lead_events.py +33 -1
- package/runtime/python/okstra_ctl/listing.py +3 -3
- package/runtime/python/okstra_ctl/log_report.py +24 -2
- package/runtime/python/okstra_ctl/manager_cli.py +92 -7
- package/runtime/python/okstra_ctl/manager_store.py +12 -10
- package/runtime/python/okstra_ctl/material.py +5 -1
- package/runtime/python/okstra_ctl/migrate.py +29 -25
- package/runtime/python/okstra_ctl/model_cli.py +3 -15
- package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
- package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
- package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
- package/runtime/python/okstra_ctl/path_hints.py +12 -0
- package/runtime/python/okstra_ctl/paths.py +21 -0
- package/runtime/python/okstra_ctl/plan_items.py +115 -4
- package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/pr_template.py +3 -2
- package/runtime/python/okstra_ctl/project_meta.py +5 -7
- package/runtime/python/okstra_ctl/recap.py +5 -4
- package/runtime/python/okstra_ctl/reconcile.py +21 -27
- package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
- package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
- package/runtime/python/okstra_ctl/render.py +74 -16
- package/runtime/python/okstra_ctl/render_final_report.py +10 -8
- package/runtime/python/okstra_ctl/report_assembly.py +509 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -1
- package/runtime/python/okstra_ctl/report_finalize.py +67 -32
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_inputs.py +72 -0
- package/runtime/python/okstra_ctl/report_language.py +3 -2
- package/runtime/python/okstra_ctl/report_markdown.py +81 -8
- package/runtime/python/okstra_ctl/report_narrative.py +351 -0
- package/runtime/python/okstra_ctl/report_projections.py +265 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
- package/runtime/python/okstra_ctl/report_views.py +3 -2
- package/runtime/python/okstra_ctl/rollup.py +65 -4
- package/runtime/python/okstra_ctl/run.py +184 -65
- package/runtime/python/okstra_ctl/run_audit.py +3 -2
- package/runtime/python/okstra_ctl/run_context.py +6 -9
- package/runtime/python/okstra_ctl/run_index_row.py +2 -8
- package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +15 -8
- package/runtime/python/okstra_ctl/session_transcript.py +27 -1
- package/runtime/python/okstra_ctl/set_work_status.py +64 -38
- package/runtime/python/okstra_ctl/stage_fix_carry.py +8 -6
- package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
- package/runtime/python/okstra_ctl/stage_map.py +96 -28
- package/runtime/python/okstra_ctl/stage_targets.py +3 -4
- package/runtime/python/okstra_ctl/team.py +3 -2
- package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
- package/runtime/python/okstra_ctl/time_report.py +51 -4
- package/runtime/python/okstra_ctl/usage_identity.py +2 -1
- package/runtime/python/okstra_ctl/usage_report.py +58 -4
- package/runtime/python/okstra_ctl/user_response.py +1431 -66
- package/runtime/python/okstra_ctl/wizard.py +11 -44
- package/runtime/python/okstra_ctl/work_categories.py +3 -2
- package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
- package/runtime/python/okstra_ctl/worker_runner.py +14 -12
- package/runtime/python/okstra_ctl/workflow.py +2 -1
- package/runtime/python/okstra_ctl/worktree.py +3 -2
- package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
- package/runtime/python/okstra_ctl/write_policy.py +4 -2
- package/runtime/python/okstra_token_usage/antigravity.py +39 -12
- package/runtime/python/okstra_token_usage/collect.py +90 -38
- package/runtime/python/okstra_token_usage/grok.py +127 -0
- package/runtime/schemas/final-report-v2.0.schema.json +9 -0
- package/runtime/schemas/final-report-v3.0.schema.json +10219 -0
- package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
- package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
- package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
- package/runtime/skills/okstra-code-review/SKILL.md +21 -11
- package/runtime/skills/okstra-container-build/SKILL.md +18 -18
- package/runtime/skills/okstra-inspect/SKILL.md +12 -11
- package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
- package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
- package/runtime/skills/okstra-inspect/facets/history.md +9 -14
- package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
- package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
- package/runtime/skills/okstra-inspect/facets/report.md +6 -10
- package/runtime/skills/okstra-inspect/facets/status.md +9 -8
- package/runtime/skills/okstra-inspect/facets/time.md +3 -3
- package/runtime/skills/okstra-manager/SKILL.md +16 -14
- package/runtime/skills/okstra-memory/SKILL.md +3 -3
- package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
- package/runtime/skills/okstra-rollup/SKILL.md +6 -16
- package/runtime/skills/okstra-run/SKILL.md +7 -7
- package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
- package/runtime/skills/okstra-setup/SKILL.md +21 -13
- package/runtime/skills/okstra-setup/references/project-config.md +2 -2
- package/runtime/skills/okstra-usage/SKILL.md +10 -10
- package/runtime/skills/okstra-user-response/SKILL.md +78 -107
- package/runtime/templates/report-writer-prompt-preamble.md +29 -19
- package/runtime/templates/reports/html/macros/forms.html +6 -4
- package/runtime/templates/reports/schedule.template.md +4 -4
- package/runtime/templates/worker-error-contract.md +17 -29
- package/runtime/validators/validate-run.py +636 -72
- 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,
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
|
567
|
-
out.append({**base, "openBlockerCount": len(
|
|
568
|
-
"openApprovalCount": approval_count,
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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__":
|