okstra 0.207.0 → 0.208.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +3 -2
  2. package/dist/cli-registry.mjs +6 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/render-bundle.mjs +1 -1
  5. package/dist/commands/lifecycle/doctor.mjs +1 -1
  6. package/dist/lib/skill-catalog.mjs +1 -0
  7. package/dist/lib/skill-catalog.mjs.map +1 -1
  8. package/docs/architecture/storage-model.md +14 -0
  9. package/docs/architecture.md +30 -9
  10. package/docs/cli.md +26 -22
  11. package/docs/contributor-change-matrix.md +1 -1
  12. package/docs/project-structure-overview.md +15 -8
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/agents/operations/explain-flow.json +6 -0
  16. package/runtime/bin/lib/okstra/cli.sh +1 -5
  17. package/runtime/bin/lib/okstra/globals.sh +0 -2
  18. package/runtime/bin/lib/okstra/usage.sh +5 -3
  19. package/runtime/bin/okstra.sh +0 -2
  20. package/runtime/prompts/duties/business-flow-investigator.json +14 -0
  21. package/runtime/prompts/lead/context-loader.md +1 -1
  22. package/runtime/prompts/lead/convergence.md +22 -7
  23. package/runtime/prompts/lead/okstra-lead-contract.md +10 -6
  24. package/runtime/prompts/lead/report-writer.md +1 -1
  25. package/runtime/prompts/lead/team-contract.md +12 -17
  26. package/runtime/prompts/profiles/_common-contract.md +3 -3
  27. package/runtime/prompts/wizard/prompts.ko.json +0 -91
  28. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +6 -0
  29. package/runtime/python/okstra_ctl/agent/invocation.py +1 -1
  30. package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +1 -0
  31. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +1 -0
  32. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +11 -125
  33. package/runtime/python/okstra_ctl/agent/standalone.py +183 -0
  34. package/runtime/python/okstra_ctl/analysis_packet.py +39 -8
  35. package/runtime/python/okstra_ctl/assignment_resolver.py +7 -1
  36. package/runtime/python/okstra_ctl/brief_frontmatter.py +10 -0
  37. package/runtime/python/okstra_ctl/business_flow/__init__.py +4 -0
  38. package/runtime/python/okstra_ctl/business_flow/cli.py +134 -0
  39. package/runtime/python/okstra_ctl/business_flow/contracts.py +268 -0
  40. package/runtime/python/okstra_ctl/business_flow/engine.py +518 -0
  41. package/runtime/python/okstra_ctl/business_flow/hooks.py +221 -0
  42. package/runtime/python/okstra_ctl/business_flow/invocation.py +170 -0
  43. package/runtime/python/okstra_ctl/business_flow/report.py +49 -0
  44. package/runtime/python/okstra_ctl/business_flow/source.py +206 -0
  45. package/runtime/python/okstra_ctl/business_flow/store.py +388 -0
  46. package/runtime/python/okstra_ctl/convergence.py +173 -2
  47. package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +18 -0
  48. package/runtime/python/okstra_ctl/convergence_provenance.py +8 -0
  49. package/runtime/python/okstra_ctl/coverage_census.py +596 -0
  50. package/runtime/python/okstra_ctl/design_surfaces.py +4 -0
  51. package/runtime/python/okstra_ctl/direct_work.py +1 -1
  52. package/runtime/python/okstra_ctl/dispatch_core.py +7 -3
  53. package/runtime/python/okstra_ctl/doctor.py +12 -6
  54. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  55. package/runtime/python/okstra_ctl/group_context.py +5 -4
  56. package/runtime/python/okstra_ctl/legacy_model_selection.py +7 -51
  57. package/runtime/python/okstra_ctl/manager_split.py +4 -1
  58. package/runtime/python/okstra_ctl/manager_view.py +9 -3
  59. package/runtime/python/okstra_ctl/model_io/lines.py +1 -24
  60. package/runtime/python/okstra_ctl/model_io/renderers.py +54 -41
  61. package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.json +1 -1
  62. package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.md +0 -8
  63. package/runtime/python/okstra_ctl/phases/error_analysis/profile.json +1 -1
  64. package/runtime/python/okstra_ctl/phases/error_analysis/profile.md +6 -8
  65. package/runtime/python/okstra_ctl/phases/feature_analysis/profile.json +1 -1
  66. package/runtime/python/okstra_ctl/phases/feature_analysis/profile.md +0 -8
  67. package/runtime/python/okstra_ctl/phases/final_verification/profile.json +1 -1
  68. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +2 -8
  69. package/runtime/python/okstra_ctl/phases/implementation/boundary.json +1 -1
  70. package/runtime/python/okstra_ctl/phases/implementation/profile.json +1 -1
  71. package/runtime/python/okstra_ctl/phases/implementation/profile.md +0 -6
  72. package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation-input.template.md +1 -1
  73. package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +4 -3
  74. package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +1 -14
  75. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.json +1 -1
  76. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +5 -8
  77. package/runtime/python/okstra_ctl/phases/implementation_option_selection/spec.md +3 -3
  78. package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +15 -5
  79. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +9 -1
  80. package/runtime/python/okstra_ctl/phases/implementation_planning/boundary.json +1 -1
  81. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +4 -2
  82. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +25 -9
  83. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.json +1 -1
  84. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +7 -10
  85. package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.json +1 -1
  86. package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.md +4 -11
  87. package/runtime/python/okstra_ctl/phases/project_analysis/profile.json +1 -1
  88. package/runtime/python/okstra_ctl/phases/project_analysis/profile.md +0 -8
  89. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +1 -1
  90. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
  91. package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.json +1 -1
  92. package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.md +10 -8
  93. package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +3 -3
  94. package/runtime/python/okstra_ctl/phases/technical_verification/profile.json +1 -1
  95. package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +0 -4
  96. package/runtime/python/okstra_ctl/plan_items.py +1 -1
  97. package/runtime/python/okstra_ctl/render.py +10 -43
  98. package/runtime/python/okstra_ctl/render_final_report.py +3 -0
  99. package/runtime/python/okstra_ctl/report_assembly.py +15 -1
  100. package/runtime/python/okstra_ctl/report_finalize.py +40 -0
  101. package/runtime/python/okstra_ctl/report_html/render.py +3 -0
  102. package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -2
  103. package/runtime/python/okstra_ctl/run.py +78 -409
  104. package/runtime/python/okstra_ctl/wizard/__init__.py +2 -24
  105. package/runtime/python/okstra_ctl/wizard/cli.py +3 -6
  106. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -35
  107. package/runtime/python/okstra_ctl/wizard/engine.py +2 -4
  108. package/runtime/python/okstra_ctl/wizard/ids.py +1 -88
  109. package/runtime/python/okstra_ctl/wizard/registry.py +36 -228
  110. package/runtime/python/okstra_ctl/wizard/render.py +2 -2
  111. package/runtime/python/okstra_ctl/wizard/roles.py +1 -3
  112. package/runtime/python/okstra_ctl/wizard/sources.py +9 -40
  113. package/runtime/python/okstra_ctl/wizard/state.py +36 -145
  114. package/runtime/python/okstra_ctl/wizard/statefile.py +27 -128
  115. package/runtime/python/okstra_ctl/wizard/steps_identity.py +22 -10
  116. package/runtime/python/okstra_ctl/wizard/steps_options.py +5 -4
  117. package/runtime/python/okstra_ctl/wizard/steps_roles.py +15 -565
  118. package/runtime/python/okstra_ctl/worker_prompt_policy.py +9 -2
  119. package/runtime/schemas/business-flow-v1.schema.json +847 -0
  120. package/runtime/schemas/convergence-groups-v2.0.schema.json +7 -0
  121. package/runtime/skills/okstra-explain-flow/SKILL.md +42 -0
  122. package/runtime/skills/okstra-inspect/facets/history.md +5 -5
  123. package/runtime/skills/okstra-run/SKILL.md +2 -2
  124. package/runtime/templates/manager/view.template.html +7 -4
  125. package/runtime/templates/reports/business-flow.template.md +106 -0
  126. package/runtime/templates/reports/html/base.template.html +14 -1
  127. package/runtime/templates/reports/html/business-flow.template.html +31 -0
  128. package/runtime/templates/reports/html/i18n/en.json +1 -0
  129. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  130. package/runtime/templates/worker-prompt-preamble.md +11 -2
  131. package/runtime/validators/checks/validate-prompt-metadata-01.py +10 -10
  132. package/runtime/validators/validate-run.py +70 -21
  133. package/runtime/validators/validate_analysis_report.py +21 -21
  134. package/runtime/python/okstra_ctl/workers.py +0 -133
@@ -118,6 +118,7 @@ def _add_materialize_parser(commands: argparse._SubParsersAction) -> None:
118
118
  "result is not their own worker result",
119
119
  )
120
120
  materialize.add_argument("--host-runtime", help=standalone_only)
121
+ materialize.add_argument("--execution-runner", choices=("cli-wrapper",), help=standalone_only)
121
122
  materialize.add_argument(
122
123
  "--terminal-backend",
123
124
  choices=(BACKEND_CLI_WRAPPER, BACKEND_CMUX_PANE),
@@ -13,7 +13,6 @@ import os
13
13
  from pathlib import Path
14
14
  import shlex
15
15
  import shutil
16
- import tempfile
17
16
  from typing import Any, Mapping, get_args
18
17
 
19
18
  from ..invocation import (
@@ -31,7 +30,7 @@ from ..invocation import (
31
30
  compose_unbound_run_prompt,
32
31
  )
33
32
  from ...assignment_environment import load_assignment_context
34
- from ...assignment_resolver import AssignmentContext, resolve_dispatch_assignment
33
+ from ..standalone import StandaloneInvocationRequest, prepare_standalone_invocation
35
34
  from ...path_hints import hydrate_active_run_context
36
35
  from ...worker_prompt_headers import worker_prompt_headers
37
36
  from ...worker_prompt_contract import (
@@ -127,6 +126,7 @@ def _materialize_run(
127
126
  )
128
127
  forbidden = {
129
128
  "host-runtime": args.host_runtime,
129
+ "execution-runner": args.execution_runner,
130
130
  "provider": args.provider,
131
131
  "model-role": args.model_role,
132
132
  "model": args.model,
@@ -142,9 +142,11 @@ def _materialize_run(
142
142
  contract = _mapping(manifest.get("agentContract"), "run agent contract")
143
143
  authorized = _mapping(contract.get("authorizedPaths"), "authorized paths")
144
144
 
145
+ # 교정 전용 디스패치는 본문을 원장에서 렌더한다(`_with_report_writer_sections`).
146
+ # 그때 지시문 출처는 원장 자신이다.
145
147
  instruction_path = _authorized_path(
146
148
  project_root,
147
- args.instruction,
149
+ args.instruction or getattr(args, "corrections", None),
148
150
  authorized.get("instructionRoots"),
149
151
  "instruction",
150
152
  must_exist=True,
@@ -343,7 +345,7 @@ def _materialize_run(
343
345
  manifest=manifest,
344
346
  active_context=active_context,
345
347
  ))
346
- body = instruction_path.read_text(encoding="utf-8")
348
+ body = instruction_path.read_text(encoding="utf-8") if args.instruction else ""
347
349
  identity_errors = validate_plan_verify_dispatch_identity(
348
350
  body, dispatch_kind=args.dispatch_kind, result_name=result_path.name,
349
351
  )
@@ -963,125 +965,9 @@ def _materialize_standalone(
963
965
  execution_provider_ids=(args.provider,),
964
966
  include_native_provider=False,
965
967
  )
966
- assignment = _standalone_assignment(
967
- context=assignment_context,
968
- provider=args.provider,
969
- model_role=args.model_role,
970
- model=args.model,
971
- )
972
- duty_root = root / f"{args.invocation_id}.duty-contracts"
973
- _snapshot_standalone_duties(duty_root)
974
- return prepare_agent_invocation(AgentInvocationRequest(
975
- invocation_id=args.invocation_id,
976
- worker_id=None,
977
- audience=args.audience,
978
- assignment_ref=None,
979
- purpose=args.purpose,
980
- assignment=assignment,
981
- instruction=AgentInstruction(
982
- anchor_lines=(),
983
- body=instruction_path.read_text(encoding="utf-8"),
984
- source_paths=(AgentInstructionSource(
985
- kind="project",
986
- path=_relative(project_root, instruction_path),
987
- ),),
988
- ),
989
- project_root=project_root,
990
- run_manifest_path=None,
991
- duty_root=duty_root,
992
- prompt_path=prompt_path,
993
- metadata_path=prompt_path.with_name(prompt_path.name + ".meta.json"),
994
- dispatch_kind="standalone",
995
- participant_ref=None,
996
- role_execution_ref=None,
997
- duty_id=None,
998
- invocation_ref=None,
999
- attempt=1,
968
+ return prepare_standalone_invocation(StandaloneInvocationRequest(
969
+ project_root=project_root, invocation_id=args.invocation_id, audience=args.audience,
970
+ purpose=args.purpose, context=assignment_context, provider=args.provider,
971
+ model_role=args.model_role, model=args.model, instruction_path=instruction_path,
972
+ prompt_path=prompt_path, requested_runner=args.execution_runner,
1000
973
  ))
1001
-
1002
-
1003
- def _standalone_assignment(
1004
- *,
1005
- context: AssignmentContext,
1006
- provider: str,
1007
- model_role: str,
1008
- model: str,
1009
- ) -> AgentModelAssignment:
1010
- host_runtime = context.environment.host_descriptor.id
1011
- resolved = resolve_dispatch_assignment(
1012
- context=context,
1013
- host_runtime=host_runtime,
1014
- role=model_role,
1015
- duty_id=model_role,
1016
- provider=provider,
1017
- model=model,
1018
- )
1019
- binding = resolved.binding
1020
- if binding is None:
1021
- raise AgentPromptCliError("standalone assignment requires a model binding")
1022
- return AgentModelAssignment(
1023
- provider=resolved.provider_id,
1024
- model=resolved.display_name,
1025
- model_execution_value=binding.resolved_execution_value,
1026
- runner=binding.runner,
1027
- host_runtime=host_runtime,
1028
- host_model_value=binding.host_model_value,
1029
- )
1030
-
1031
-
1032
- def _snapshot_standalone_duties(destination: Path) -> None:
1033
- # 부모 개수를 세면 체크아웃(`<root>/scripts/okstra_ctl/agent/prompt_cli/`)
1034
- # 에서만 맞는다 — 설치본은 패키지가 `~/.okstra/lib/python/` 이라 prompts 가
1035
- # 한 단계 위(`~/.okstra/prompts/`)에 있다. duties 를 실제로 가진 루트를
1036
- # 탐색해서 해소한다.
1037
- from ...paths import find_asset_root
1038
-
1039
- duties_relative = ("prompts", "duties")
1040
- root = find_asset_root(duties_relative, is_present=Path.is_dir)
1041
- if root is None:
1042
- raise AgentPromptCliError(
1043
- "duty contract source not found: no prompts/duties under "
1044
- "OKSTRA_HOME or this checkout"
1045
- )
1046
- source = root.joinpath(*duties_relative)
1047
- # 공통 계약은 직무가 아니라 `agents/` 가 소유한다(ADR-0017). 동결본은 한
1048
- # 뿌리여야 하므로 여기서 함께 싣는다.
1049
- common_root = find_asset_root(("agents", "common.json"), is_present=Path.is_file)
1050
- if common_root is None:
1051
- raise AgentPromptCliError(
1052
- "agent common contract not found: no agents/common.json under "
1053
- "OKSTRA_HOME or this checkout"
1054
- )
1055
- common_source = common_root / "agents" / "common.json"
1056
-
1057
- def _staged_digest() -> str:
1058
- staged = Path(tempfile.mkdtemp(prefix=".okstra-standalone-duties."))
1059
- try:
1060
- shutil.copytree(source, staged, dirs_exist_ok=True)
1061
- shutil.copyfile(common_source, staged / "common.json")
1062
- shutil.copytree(
1063
- common_source.parent / "roles", staged / "roles", dirs_exist_ok=True
1064
- )
1065
- return digest_duty_catalog(staged)
1066
- finally:
1067
- shutil.rmtree(staged, ignore_errors=True)
1068
-
1069
- if destination.exists():
1070
- if not destination.is_dir() or digest_duty_catalog(destination) != _staged_digest():
1071
- raise AgentPromptCliError("standalone duty snapshot conflicts with existing files")
1072
- return
1073
- temporary = Path(tempfile.mkdtemp(prefix=f".{destination.name}.", dir=destination.parent))
1074
- try:
1075
- shutil.copytree(source, temporary, dirs_exist_ok=True)
1076
- shutil.copyfile(common_source, temporary / "common.json")
1077
- shutil.copytree(
1078
- common_source.parent / "roles", temporary / "roles", dirs_exist_ok=True
1079
- )
1080
- try:
1081
- os.rename(temporary, destination)
1082
- except FileExistsError:
1083
- if digest_duty_catalog(destination) != _staged_digest():
1084
- raise AgentPromptCliError("standalone duty snapshot publication conflict")
1085
- finally:
1086
- if temporary.exists():
1087
- shutil.rmtree(temporary)
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..assignment_resolver import AssignmentContext, resolve_dispatch_assignment
10
+ from .invocation import (
11
+ AgentAudience,
12
+ AgentInstruction,
13
+ AgentInstructionSource,
14
+ AgentInvocationError,
15
+ AgentInvocationRequest,
16
+ AgentModelAssignment,
17
+ PreparedAgentInvocation,
18
+ digest_duty_catalog,
19
+ prepare_agent_invocation,
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class StandaloneInvocationRequest:
25
+ project_root: Path
26
+ invocation_id: str
27
+ audience: AgentAudience
28
+ purpose: str
29
+ context: AssignmentContext
30
+ provider: str
31
+ model_role: str
32
+ model: str
33
+ instruction_path: Path
34
+ prompt_path: Path
35
+ requested_runner: str | None = None
36
+
37
+
38
+ def prepare_standalone_invocation(
39
+ request: StandaloneInvocationRequest,
40
+ ) -> PreparedAgentInvocation:
41
+ root = request.project_root / ".okstra" / "agent-invocations" / request.purpose
42
+ if not root.resolve().is_relative_to(request.project_root.resolve()):
43
+ raise AgentInvocationError("standalone invocation root escapes project")
44
+ duty_root = root / f"{request.invocation_id}.duty-contracts"
45
+ snapshot_standalone_duties(duty_root)
46
+ assignment = standalone_assignment(
47
+ context=request.context,
48
+ provider=request.provider,
49
+ model_role=request.model_role,
50
+ model=request.model,
51
+ requested_runner=request.requested_runner,
52
+ )
53
+ return prepare_agent_invocation(
54
+ AgentInvocationRequest(
55
+ invocation_id=request.invocation_id,
56
+ worker_id=None,
57
+ audience=request.audience,
58
+ assignment_ref=None,
59
+ purpose=request.purpose,
60
+ assignment=assignment,
61
+ instruction=AgentInstruction(
62
+ anchor_lines=(),
63
+ body=request.instruction_path.read_text(encoding="utf-8"),
64
+ source_paths=(
65
+ AgentInstructionSource(
66
+ kind="project",
67
+ path=request.instruction_path.relative_to(
68
+ request.project_root
69
+ ).as_posix(),
70
+ ),
71
+ ),
72
+ ),
73
+ project_root=request.project_root,
74
+ run_manifest_path=None,
75
+ duty_root=duty_root,
76
+ prompt_path=request.prompt_path,
77
+ metadata_path=request.prompt_path.with_name(
78
+ request.prompt_path.name + ".meta.json"
79
+ ),
80
+ dispatch_kind="standalone",
81
+ participant_ref=None,
82
+ role_execution_ref=None,
83
+ duty_id=None,
84
+ invocation_ref=None,
85
+ attempt=1,
86
+ )
87
+ )
88
+
89
+
90
+ def standalone_assignment(
91
+ *,
92
+ context: AssignmentContext,
93
+ provider: str,
94
+ model_role: str,
95
+ model: str,
96
+ requested_runner: str | None = None,
97
+ ) -> AgentModelAssignment:
98
+ host_runtime = context.environment.host_descriptor.id
99
+ resolved = resolve_dispatch_assignment(
100
+ context=context,
101
+ host_runtime=host_runtime,
102
+ role=model_role,
103
+ duty_id=model_role,
104
+ provider=provider,
105
+ model=model,
106
+ requested_runner=requested_runner,
107
+ )
108
+ binding = resolved.binding
109
+ if binding is None:
110
+ raise AgentInvocationError("standalone assignment requires a model binding")
111
+ return AgentModelAssignment(
112
+ provider=resolved.provider_id,
113
+ model=resolved.display_name,
114
+ model_execution_value=binding.resolved_execution_value,
115
+ runner=binding.runner,
116
+ host_runtime=host_runtime,
117
+ host_model_value=binding.host_model_value,
118
+ )
119
+
120
+
121
+ def snapshot_standalone_duties(destination: Path) -> None:
122
+ # 부모 개수를 세면 체크아웃(`<root>/scripts/okstra_ctl/agent/prompt_cli/`)
123
+ # 에서만 맞는다 — 설치본은 패키지가 `~/.okstra/lib/python/` 이라 prompts 가
124
+ # 한 단계 위(`~/.okstra/prompts/`)에 있다. duties 를 실제로 가진 루트를
125
+ # 탐색해서 해소한다.
126
+ from ..paths import find_asset_root
127
+
128
+ duties_relative = ("prompts", "duties")
129
+ root = find_asset_root(duties_relative, is_present=Path.is_dir)
130
+ if root is None:
131
+ raise AgentInvocationError(
132
+ "duty contract source not found: no prompts/duties under "
133
+ "OKSTRA_HOME or this checkout"
134
+ )
135
+ source = root.joinpath(*duties_relative)
136
+ # 공통 계약은 직무가 아니라 `agents/` 가 소유한다(ADR-0017). 동결본은 한
137
+ # 뿌리여야 하므로 여기서 함께 싣는다.
138
+ common_root = find_asset_root(("agents", "common.json"), is_present=Path.is_file)
139
+ if common_root is None:
140
+ raise AgentInvocationError(
141
+ "agent common contract not found: no agents/common.json under "
142
+ "OKSTRA_HOME or this checkout"
143
+ )
144
+ common_source = common_root / "agents" / "common.json"
145
+
146
+ def _staged_digest() -> str:
147
+ staged = Path(tempfile.mkdtemp(prefix=".okstra-standalone-duties."))
148
+ try:
149
+ _copy_standalone_contracts(source, common_source, staged)
150
+ return digest_duty_catalog(staged)
151
+ finally:
152
+ shutil.rmtree(staged, ignore_errors=True)
153
+
154
+ if destination.exists():
155
+ if (
156
+ not destination.is_dir()
157
+ or digest_duty_catalog(destination) != _staged_digest()
158
+ ):
159
+ raise AgentInvocationError(
160
+ "standalone duty snapshot conflicts with existing files"
161
+ )
162
+ return
163
+ temporary = Path(
164
+ tempfile.mkdtemp(prefix=f".{destination.name}.", dir=destination.parent)
165
+ )
166
+ try:
167
+ _copy_standalone_contracts(source, common_source, temporary)
168
+ try:
169
+ os.rename(temporary, destination)
170
+ except FileExistsError:
171
+ if digest_duty_catalog(destination) != _staged_digest():
172
+ raise AgentInvocationError(
173
+ "standalone duty snapshot publication conflict"
174
+ )
175
+ finally:
176
+ if temporary.exists():
177
+ shutil.rmtree(temporary)
178
+
179
+
180
+ def _copy_standalone_contracts(source: Path, common: Path, target: Path) -> None:
181
+ shutil.copytree(source, target, dirs_exist_ok=True)
182
+ shutil.copyfile(common, target / "common.json")
183
+ shutil.copytree(common.parent / "roles", target / "roles", dirs_exist_ok=True)
@@ -25,6 +25,7 @@ BRIEF_SECTIONS = (
25
25
  CANONICAL_BRIEF_SECTIONS = (
26
26
  "Source Material",
27
27
  "Context",
28
+ "Project Scope",
28
29
  "Problem / Symptom",
29
30
  "Desired Outcome",
30
31
  "Expected Behavior",
@@ -107,6 +108,8 @@ def build_analysis_packet(
107
108
  stage_ledger_notice: str = "",
108
109
  prior_planning_summary: str = "",
109
110
  direct_work_text: str = "",
111
+ business_knowledge_text: str = "",
112
+ coverage_census_text: str = "",
110
113
  ) -> str:
111
114
  """Return the primary compact input for Claude/Codex/Antigravity analysers.
112
115
 
@@ -120,6 +123,9 @@ def build_analysis_packet(
120
123
  `group_context_path` is the task-group context prepare copied into the
121
124
  instruction set (`task-group-context.md`); `None` means the group has no
122
125
  such document and the packet carries no `## Task-Group Context` section.
126
+
127
+ `coverage_census_text` is the `## Coverage Census` block
128
+ `coverage_census.render_census_section` rendered; empty means no census.
123
129
  """
124
130
  brief_text = task_brief_path.read_text(encoding="utf-8")
125
131
  group_context_text = _read_optional(group_context_path) if group_context_path else ""
@@ -155,6 +161,10 @@ def build_analysis_packet(
155
161
  fix_history_text, prior_planning_summary, stage_ledger_json)),
156
162
  ))
157
163
  parts.extend(_profile_block(task_type, profile_text))
164
+ if business_knowledge_text:
165
+ parts.extend(["", business_knowledge_text, ""])
166
+ if coverage_census_text:
167
+ parts.extend(["", coverage_census_text])
158
168
  parts.extend(_reference_block(reference_text))
159
169
  parts.extend(_fix_history_block(fix_history_text))
160
170
  parts.extend(_stage_ledger_block(stage_ledger_json))
@@ -167,7 +177,7 @@ def build_analysis_packet(
167
177
 
168
178
 
169
179
  _HEADING_RE = re.compile(r"\A#{1,2} \S")
170
- _FENCE_RE = re.compile(r"\A\s*(```|~~~)")
180
+ _FENCE_RE = re.compile(r"\A\s*(?P<marker>`{3,}|~{3,})(?P<info>.*)\Z")
171
181
  # 목차 자신이 차지하는 줄 수 중 표제 행을 뺀 나머지. 행 수는 표제 개수로
172
182
  # 정해지므로 삽입 전에 전체 이동량을 계산할 수 있다.
173
183
  _INDEX_PREAMBLE_LINES = 5
@@ -273,11 +283,7 @@ def _heading_lines(lines: list[str]) -> list[tuple[int, str]]:
273
283
  not a section.
274
284
  """
275
285
  out: list[tuple[int, str]] = []
276
- fenced = False
277
- for number, line in enumerate(lines, start=1):
278
- if _FENCE_RE.match(line):
279
- fenced = not fenced
280
- continue
286
+ for number, (line, fenced) in enumerate(zip(lines, _fence_mask(lines)), start=1):
281
287
  if fenced or not _HEADING_RE.match(line):
282
288
  continue
283
289
  if line.startswith("# OKSTRA Analysis Packet"):
@@ -286,6 +292,30 @@ def _heading_lines(lines: list[str]) -> list[tuple[int, str]]:
286
292
  return out
287
293
 
288
294
 
295
+ def _fence_mask(lines: list[str]) -> list[bool]:
296
+ """Per line, whether it belongs to a fenced block (fence lines included).
297
+
298
+ A fence closes only on the same character at least as long, with no info
299
+ string (CommonMark). Briefs quote whole documents in ````` ```` ````` fences
300
+ that themselves contain ``` blocks and `## ` headings.
301
+ """
302
+ mask: list[bool] = []
303
+ opener: str | None = None
304
+ for line in lines:
305
+ match = _FENCE_RE.match(line)
306
+ if opener is None:
307
+ if match:
308
+ opener = match.group("marker")
309
+ mask.append(opener is not None)
310
+ continue
311
+ mask.append(True)
312
+ if (match and not match.group("info").strip()
313
+ and match.group("marker")[0] == opener[0]
314
+ and len(match.group("marker")) >= len(opener)):
315
+ opener = None
316
+ return mask
317
+
318
+
289
319
  def _packet_frontmatter(brief_text: str, task_key: str) -> str:
290
320
  frontmatter = _extract_frontmatter(brief_text)
291
321
  if frontmatter:
@@ -697,8 +727,9 @@ def _section_map(text: str) -> dict[str, str]:
697
727
  result: dict[str, list[str]] = {}
698
728
  aliases: dict[str, str] = {}
699
729
  current = ""
700
- for line in _strip_frontmatter(text).splitlines():
701
- if line.startswith("## "):
730
+ lines = _strip_frontmatter(text).splitlines()
731
+ for line, fenced in zip(lines, _fence_mask(lines)):
732
+ if line.startswith("## ") and not fenced:
702
733
  current = line[3:].strip()
703
734
  result.setdefault(current, [])
704
735
  alias = _heading_alias(current)
@@ -240,6 +240,7 @@ def resolve_model_assignment(
240
240
  environment: AssignmentEnvironment,
241
241
  entry_mode: Literal["current-session", "new-session"] = "new-session",
242
242
  require_selectable: bool = True,
243
+ requested_runner: str | None = None,
243
244
  ) -> ResolvedAssignment:
244
245
  """Resolve one canonical model through the selected host execution path.
245
246
 
@@ -269,6 +270,7 @@ def resolve_model_assignment(
269
270
  model,
270
271
  pool,
271
272
  environment,
273
+ requested_runner,
272
274
  )
273
275
  return _resolved_assignment(instance, model, host, entry_mode, binding)
274
276
 
@@ -281,6 +283,7 @@ def resolve_dispatch_assignment(
281
283
  duty_id: str,
282
284
  provider: str,
283
285
  model: str = "",
286
+ requested_runner: str | None = None,
284
287
  ) -> ResolvedAssignment:
285
288
  """Resolve one legacy provider/model input through the shared core."""
286
289
  canonical_role = normalize_role(role)
@@ -298,6 +301,7 @@ def resolve_dispatch_assignment(
298
301
  pool=pool,
299
302
  host=host,
300
303
  environment=context.environment,
304
+ requested_runner=requested_runner,
301
305
  )
302
306
 
303
307
 
@@ -604,10 +608,12 @@ def _first_supported_binding(
604
608
  model: ResolvedModel,
605
609
  pool: ModelPool,
606
610
  environment: AssignmentEnvironment,
611
+ requested_runner: str | None = None,
607
612
  ) -> HostModelBinding:
608
613
  provider = pool.resolve_provider(model.provider_id)
609
614
  errors: list[str] = []
610
- for runner in _runner_candidates(instance, environment, provider):
615
+ runners = (requested_runner,) if requested_runner else _runner_candidates(instance, environment, provider)
616
+ for runner in runners:
611
617
  try:
612
618
  resolve_assignment_runner(
613
619
  host=environment.host_descriptor,
@@ -45,5 +45,15 @@ def read_brief_frontmatter(path: Path) -> dict[str, str]:
45
45
  return out
46
46
 
47
47
 
48
+ def brief_task_id(frontmatter: Mapping[str, str]) -> str:
49
+ """The task-id a brief belongs to, unslugified; ``''`` when it names none.
50
+
51
+ okstra-manager splits one ticket into per-project child tasks keyed by the
52
+ ticket alone, so its briefs record that key in ``task-id`` while ``brief-id``
53
+ stays the long file stem. Other briefs carry only ``brief-id``.
54
+ """
55
+ return frontmatter.get("task-id") or frontmatter.get("brief-id", "")
56
+
57
+
48
58
  def has_reporter_confirmation_contract(frontmatter: Mapping[str, str]) -> bool:
49
59
  return "reporter-confirmations" in frontmatter
@@ -0,0 +1,4 @@
1
+ from .engine import explain_flow
2
+ from .store import query_knowledge
3
+
4
+ __all__ = ["explain_flow", "query_knowledge"]
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from ..json_boundary import JsonBoundaryError
9
+ from ..report_language import resolve_report_language
10
+ from .contracts import BusinessFlowError, FlowRequest
11
+ from .engine import explain_flow, load_execution, rerun_explanation
12
+ from .store import query_knowledge
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ explain = argparse.ArgumentParser(add_help=False)
17
+ explain.add_argument("--question", dest="question_flag", default="")
18
+ explain.add_argument(
19
+ "--mode", choices=("current", "pre", "post"), default="current"
20
+ )
21
+ explain.add_argument("--task-key", default="")
22
+ explain.add_argument("--baseline", default="")
23
+ explain.add_argument("--source-root", type=Path)
24
+ explain.add_argument("--report-language", default="")
25
+ explain.add_argument(
26
+ "--source-artifact",
27
+ action="append",
28
+ default=[],
29
+ help="existing read-only verification record",
30
+ )
31
+ explain.add_argument("--host-runtime", default="claude-code")
32
+ explain.add_argument("--project-root", type=Path, default=Path.cwd())
33
+ explain.add_argument("--json", action="store_true")
34
+ parser = argparse.ArgumentParser(prog="okstra explain-flow", parents=[explain])
35
+ parser.set_defaults(command="explain", question="")
36
+ commands = parser.add_subparsers(dest="command")
37
+ explicit = commands.add_parser(
38
+ "explain", help="investigate a business question", parents=[explain]
39
+ )
40
+ explicit.add_argument("question", nargs="?", default="")
41
+ knowledge = commands.add_parser(
42
+ "knowledge", help="query relevant shared business knowledge"
43
+ )
44
+ knowledge.add_argument("--query", required=True)
45
+ rerun = commands.add_parser(
46
+ "rerun", help="retry only an explanation and retain its history"
47
+ )
48
+ rerun.add_argument("--execution", required=True)
49
+ rerun.add_argument("--source-root", type=Path)
50
+ status = commands.add_parser("status", help="inspect an explanation execution")
51
+ status.add_argument("--execution", required=True)
52
+ for child in (knowledge, rerun, status):
53
+ child.add_argument("--project-root", type=Path, default=Path.cwd())
54
+ child.add_argument("--json", action="store_true")
55
+ return parser
56
+
57
+
58
+ def main(argv: list[str] | None = None) -> int:
59
+ arguments = list(sys.argv[1:] if argv is None else argv)
60
+ if (
61
+ arguments
62
+ and not arguments[0].startswith("-")
63
+ and arguments[0]
64
+ not in {
65
+ "explain",
66
+ "knowledge",
67
+ "rerun",
68
+ "status",
69
+ "-h",
70
+ "--help",
71
+ }
72
+ ):
73
+ arguments.insert(0, "explain")
74
+ args = build_parser().parse_args(arguments)
75
+ args.command = args.command or "explain"
76
+ try:
77
+ if args.command == "explain":
78
+ result = explain_flow(
79
+ FlowRequest(
80
+ args.project_root,
81
+ args.question_flag or args.question,
82
+ args.mode,
83
+ args.task_key,
84
+ args.baseline,
85
+ args.source_root,
86
+ args.report_language
87
+ or resolve_report_language(args.project_root, {}, {}),
88
+ tuple(args.source_artifact),
89
+ args.host_runtime,
90
+ )
91
+ )
92
+ elif args.command == "knowledge":
93
+ result = query_knowledge(args.project_root, args.query)
94
+ elif args.command == "rerun":
95
+ result = rerun_explanation(
96
+ args.project_root, args.execution, source_root=args.source_root
97
+ )
98
+ else:
99
+ result = load_execution(args.project_root, args.execution)
100
+ print(
101
+ json.dumps(result, ensure_ascii=False, indent=2)
102
+ if args.json
103
+ else render_result(result)
104
+ )
105
+ return 1 if result.get("status") == "failed" else 0
106
+ except (OSError, BusinessFlowError, JsonBoundaryError) as exc:
107
+ print(f"explain-flow: {exc}", file=sys.stderr)
108
+ return 1
109
+
110
+
111
+ def render_result(result: dict) -> str:
112
+ if "claims" in result:
113
+ lines = ["Shared Business Knowledge", ""]
114
+ for row in result["claims"]:
115
+ lines.append(
116
+ f"- {row['value']} ({row['level']}; {row['freshness']}; {row['status']})"
117
+ )
118
+ lines.append(f" - Claim: {row['id']}; owner: {row['ownerRoot']}")
119
+ lines.extend(f"- Read error: {error}" for error in result["errors"])
120
+ return "\n".join(lines)
121
+ lines = [
122
+ "Business Flow Explanation",
123
+ f"Status: {result['status']}",
124
+ f"Execution: {result['id']}",
125
+ ]
126
+ lines.extend(f"{kind}: {path}" for kind, path in result.get("reports", {}).items())
127
+ if result.get("error"):
128
+ lines.append(f"Error: {result['error']}")
129
+ lines.append(f"Retry: okstra explain-flow rerun --execution {result['id']}")
130
+ return "\n".join(lines)
131
+
132
+
133
+ if __name__ == "__main__":
134
+ raise SystemExit(main())