okstra 0.179.2 → 0.180.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli-registry.mjs +14 -0
- 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/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/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +10 -10
- package/docs/cli.md +11 -8
- package/docs/project-structure-overview.md +15 -6
- package/docs/task-process/implementation-planning.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +15 -164
- package/runtime/prompts/launch.template.md +6 -5
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/convergence.md +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +19 -18
- package/runtime/prompts/lead/plan-body-verification.md +39 -18
- package/runtime/prompts/lead/report-writer.md +64 -423
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- 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/implementation-planning.md +13 -11
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/project-analysis.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/agent_activity.py +23 -3
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +6 -6
- package/runtime/python/okstra_ctl/analysis_packet.py +43 -2
- package/runtime/python/okstra_ctl/approval_decisions.py +327 -0
- package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +62 -4
- package/runtime/python/okstra_ctl/dispatch_state.py +29 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +24 -15
- package/runtime/python/okstra_ctl/incremental_carry.py +128 -16
- package/runtime/python/okstra_ctl/incremental_scope.py +4 -1
- package/runtime/python/okstra_ctl/path_hints.py +12 -0
- package/runtime/python/okstra_ctl/paths.py +12 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +113 -16
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/render.py +48 -1
- package/runtime/python/okstra_ctl/render_final_report.py +7 -6
- package/runtime/python/okstra_ctl/report_assembly.py +354 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -1
- package/runtime/python/okstra_ctl/report_finalize.py +60 -22
- package/runtime/python/okstra_ctl/report_inputs.py +72 -0
- package/runtime/python/okstra_ctl/report_markdown.py +69 -8
- package/runtime/python/okstra_ctl/report_narrative.py +319 -0
- package/runtime/python/okstra_ctl/report_projections.py +265 -0
- package/runtime/python/okstra_ctl/run.py +25 -9
- package/runtime/python/okstra_ctl/schema_excerpt.py +11 -6
- package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -4
- package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
- package/runtime/python/okstra_ctl/stage_map.py +70 -22
- package/runtime/python/okstra_ctl/team.py +1 -1
- package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +35 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
- package/runtime/schemas/final-report-v3.0.schema.json +10210 -0
- package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
- package/runtime/templates/report-writer-prompt-preamble.md +15 -21
- package/runtime/templates/reports/html/macros/forms.html +6 -4
- package/runtime/validators/validate-run.py +258 -10
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""선임 에이전트가 소유하는 승인 결정 입력 원장."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
from .convergence_store import write_json_atomic
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DISPOSITIONS = frozenset({"select", "accept-risk", "request-revision", "reject"})
|
|
17
|
+
REACHES = frozenset({"in-repo", "cross-repo"})
|
|
18
|
+
SCOPE_EFFECTS = frozenset({"new-schema", "deferrable"})
|
|
19
|
+
CLASSIFICATIONS = frozenset(
|
|
20
|
+
{"user-decision", "noncritical-dissent", "correctness-critical"}
|
|
21
|
+
)
|
|
22
|
+
_FORBIDDEN_BY_CLASSIFICATION = {
|
|
23
|
+
"correctness-critical": frozenset({"select", "accept-risk"}),
|
|
24
|
+
"noncritical-dissent": frozenset({"select"}),
|
|
25
|
+
"user-decision": frozenset(),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApprovalDecisionError(ValueError):
|
|
30
|
+
"""승인 결정 입력이 역할 계약이나 선택지 불변식을 위반했다."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _required(value: str, field: str) -> str:
|
|
34
|
+
if not isinstance(value, str) or not value.strip():
|
|
35
|
+
raise ApprovalDecisionError(f"{field} must be a non-empty string")
|
|
36
|
+
return value.strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class DecisionOption:
|
|
41
|
+
role: str
|
|
42
|
+
answer: str
|
|
43
|
+
rationale: str
|
|
44
|
+
disposition: str
|
|
45
|
+
reach: str
|
|
46
|
+
scope_effects: tuple[str, ...]
|
|
47
|
+
added_work: str
|
|
48
|
+
direction_change: str
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if self.role not in {"recommended", "alternative"}:
|
|
52
|
+
raise ApprovalDecisionError(f"invalid option role: {self.role}")
|
|
53
|
+
for field in ("answer", "rationale", "added_work", "direction_change"):
|
|
54
|
+
_required(getattr(self, field), field)
|
|
55
|
+
if self.disposition not in DISPOSITIONS:
|
|
56
|
+
raise ApprovalDecisionError(f"invalid disposition: {self.disposition}")
|
|
57
|
+
if self.reach not in REACHES:
|
|
58
|
+
raise ApprovalDecisionError(f"invalid reach: {self.reach}")
|
|
59
|
+
unknown = sorted(set(self.scope_effects) - SCOPE_EFFECTS)
|
|
60
|
+
if unknown or len(set(self.scope_effects)) != len(self.scope_effects):
|
|
61
|
+
raise ApprovalDecisionError(f"invalid scope_effects: {unknown}")
|
|
62
|
+
|
|
63
|
+
def to_payload(self) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"role": self.role,
|
|
66
|
+
"answer": self.answer,
|
|
67
|
+
"rationale": self.rationale,
|
|
68
|
+
"disposition": self.disposition,
|
|
69
|
+
"reach": self.reach,
|
|
70
|
+
"scopeEffects": list(self.scope_effects),
|
|
71
|
+
"addedWork": self.added_work,
|
|
72
|
+
"directionChange": self.direction_change,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _new_ledger(task_key: str, task_type: str, run_seq: str) -> dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"schemaVersion": "1.0",
|
|
79
|
+
"owner": "lead",
|
|
80
|
+
"taskKey": _required(task_key, "task_key"),
|
|
81
|
+
"taskType": _required(task_type, "task_type"),
|
|
82
|
+
"runSeq": _required(run_seq, "run_seq"),
|
|
83
|
+
"activeClarifications": [],
|
|
84
|
+
"carriedDecisions": [],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_ledger(path: Path) -> dict[str, Any]:
|
|
89
|
+
try:
|
|
90
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
91
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
92
|
+
raise ApprovalDecisionError(f"cannot read approval ledger {path}: {exc}") from exc
|
|
93
|
+
if not isinstance(value, dict) or value.get("owner") != "lead":
|
|
94
|
+
raise ApprovalDecisionError(f"approval ledger owner must be lead: {path}")
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ledger(path: Path, task_key: str, task_type: str, run_seq: str) -> dict[str, Any]:
|
|
99
|
+
if not path.is_file():
|
|
100
|
+
return _new_ledger(task_key, task_type, run_seq)
|
|
101
|
+
ledger = _read_ledger(path)
|
|
102
|
+
expected = (task_key, task_type, run_seq)
|
|
103
|
+
actual = (ledger.get("taskKey"), ledger.get("taskType"), ledger.get("runSeq"))
|
|
104
|
+
if actual != expected:
|
|
105
|
+
raise ApprovalDecisionError("approval ledger identity does not match this run")
|
|
106
|
+
return ledger
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _validate_option_set(
|
|
110
|
+
options: Sequence[DecisionOption], classification: str,
|
|
111
|
+
recommended_disposition: str,
|
|
112
|
+
) -> None:
|
|
113
|
+
if len(options) < 2:
|
|
114
|
+
raise ApprovalDecisionError("a decision requires at least two options")
|
|
115
|
+
if sum(option.role == "recommended" for option in options) != 1:
|
|
116
|
+
raise ApprovalDecisionError("a decision requires exactly one recommended option")
|
|
117
|
+
forbidden = _FORBIDDEN_BY_CLASSIFICATION[classification]
|
|
118
|
+
used = {recommended_disposition, *(option.disposition for option in options)}
|
|
119
|
+
invalid = sorted(used & forbidden)
|
|
120
|
+
if invalid:
|
|
121
|
+
raise ApprovalDecisionError(f"{classification} forbids dispositions: {invalid}")
|
|
122
|
+
recommended = next(option for option in options if option.role == "recommended")
|
|
123
|
+
if recommended.disposition != recommended_disposition:
|
|
124
|
+
raise ApprovalDecisionError(
|
|
125
|
+
"recommended_disposition must match the recommended option"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _decision_row(
|
|
130
|
+
*, clarification_id: str, ticket_id: str, statement: str,
|
|
131
|
+
expected_form: str, classification: str, origin: str,
|
|
132
|
+
user_confirmation: str, unblock_condition: str,
|
|
133
|
+
recommended_disposition: str, options: Sequence[DecisionOption],
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
if classification not in CLASSIFICATIONS:
|
|
136
|
+
raise ApprovalDecisionError(f"invalid classification: {classification}")
|
|
137
|
+
if recommended_disposition not in DISPOSITIONS:
|
|
138
|
+
raise ApprovalDecisionError(
|
|
139
|
+
f"invalid recommended_disposition: {recommended_disposition}"
|
|
140
|
+
)
|
|
141
|
+
_validate_option_set(options, classification, recommended_disposition)
|
|
142
|
+
return {
|
|
143
|
+
"id": _required(clarification_id, "clarification_id"),
|
|
144
|
+
"ticketId": _required(ticket_id, "ticket_id"),
|
|
145
|
+
"kind": "decision",
|
|
146
|
+
"statement": _required(statement, "statement"),
|
|
147
|
+
"expectedForm": _required(expected_form, "expected_form"),
|
|
148
|
+
"blocks": "approval",
|
|
149
|
+
"origin": _required(origin, "origin"),
|
|
150
|
+
"userConfirmation": _required(user_confirmation, "user_confirmation"),
|
|
151
|
+
"approval": {
|
|
152
|
+
"classification": classification,
|
|
153
|
+
"unblockCondition": _required(unblock_condition, "unblock_condition"),
|
|
154
|
+
"recommendedDisposition": recommended_disposition,
|
|
155
|
+
},
|
|
156
|
+
"options": [option.to_payload() for option in options],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def open_decision(
|
|
161
|
+
*, ledger_path: Path, task_key: str, task_type: str, run_seq: str,
|
|
162
|
+
clarification_id: str, ticket_id: str, statement: str, expected_form: str,
|
|
163
|
+
classification: str, origin: str, user_confirmation: str,
|
|
164
|
+
unblock_condition: str, recommended_disposition: str,
|
|
165
|
+
options: Sequence[DecisionOption],
|
|
166
|
+
) -> None:
|
|
167
|
+
row = _decision_row(
|
|
168
|
+
clarification_id=clarification_id, ticket_id=ticket_id,
|
|
169
|
+
statement=statement, expected_form=expected_form,
|
|
170
|
+
classification=classification, origin=origin,
|
|
171
|
+
user_confirmation=user_confirmation, unblock_condition=unblock_condition,
|
|
172
|
+
recommended_disposition=recommended_disposition, options=options,
|
|
173
|
+
)
|
|
174
|
+
ledger = _ledger(ledger_path, task_key, task_type, run_seq)
|
|
175
|
+
active = ledger.get("activeClarifications")
|
|
176
|
+
if not isinstance(active, list):
|
|
177
|
+
raise ApprovalDecisionError("activeClarifications must be an array")
|
|
178
|
+
if any(item.get("id") == clarification_id for item in active if isinstance(item, Mapping)):
|
|
179
|
+
raise ApprovalDecisionError(f"duplicate active clarification: {clarification_id}")
|
|
180
|
+
active.append(row)
|
|
181
|
+
write_json_atomic(ledger_path, ledger)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def resolve_decision(
|
|
185
|
+
ledger_path: Path, clarification_id: str, *, disposition: str,
|
|
186
|
+
user_text: str, user_response_ref: str, check_refs: Sequence[str],
|
|
187
|
+
) -> None:
|
|
188
|
+
if disposition not in DISPOSITIONS:
|
|
189
|
+
raise ApprovalDecisionError(f"invalid disposition: {disposition}")
|
|
190
|
+
resolution = {
|
|
191
|
+
"disposition": disposition,
|
|
192
|
+
"userText": _required(user_text, "user_text"),
|
|
193
|
+
"userResponseRef": _required(user_response_ref, "user_response_ref"),
|
|
194
|
+
"checkRefs": [_required(ref, "check_refs") for ref in check_refs],
|
|
195
|
+
}
|
|
196
|
+
if not resolution["checkRefs"]:
|
|
197
|
+
raise ApprovalDecisionError("check_refs requires at least one value")
|
|
198
|
+
invalid_refs = [
|
|
199
|
+
ref for ref in resolution["checkRefs"]
|
|
200
|
+
if re.fullmatch(r"A-\d{3,}", ref) is None
|
|
201
|
+
]
|
|
202
|
+
if invalid_refs:
|
|
203
|
+
raise ApprovalDecisionError(f"check_refs must be activity IDs: {invalid_refs}")
|
|
204
|
+
ledger = _read_ledger(ledger_path)
|
|
205
|
+
active = ledger.get("activeClarifications")
|
|
206
|
+
rows = active if isinstance(active, list) else []
|
|
207
|
+
matches = [row for row in rows if isinstance(row, dict) and row.get("id") == clarification_id]
|
|
208
|
+
if len(matches) != 1:
|
|
209
|
+
raise ApprovalDecisionError(f"active clarification not found: {clarification_id}")
|
|
210
|
+
matches[0]["resolutionInput"] = resolution
|
|
211
|
+
write_json_atomic(ledger_path, ledger)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def carry_decision(
|
|
215
|
+
ledger_path: Path, *, source_run_ref: str, decision: Mapping[str, Any],
|
|
216
|
+
) -> None:
|
|
217
|
+
ledger = _read_ledger(ledger_path)
|
|
218
|
+
carried = ledger.get("carriedDecisions")
|
|
219
|
+
if not isinstance(carried, list):
|
|
220
|
+
raise ApprovalDecisionError("carriedDecisions must be an array")
|
|
221
|
+
row = {
|
|
222
|
+
"sourceRunRef": _required(source_run_ref, "source_run_ref"),
|
|
223
|
+
"decision": deepcopy(dict(decision)),
|
|
224
|
+
}
|
|
225
|
+
if row not in carried:
|
|
226
|
+
carried.append(row)
|
|
227
|
+
write_json_atomic(ledger_path, ledger)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _options_from_args(args: argparse.Namespace) -> tuple[DecisionOption, ...]:
|
|
231
|
+
fields = (
|
|
232
|
+
args.option_role, args.option_answer, args.option_rationale,
|
|
233
|
+
args.option_disposition, args.option_reach, args.option_scope_effect,
|
|
234
|
+
args.option_added_work, args.option_direction_change,
|
|
235
|
+
)
|
|
236
|
+
if len({len(values) for values in fields}) != 1:
|
|
237
|
+
raise ApprovalDecisionError("every repeated option field needs the same count")
|
|
238
|
+
return tuple(
|
|
239
|
+
DecisionOption(role, answer, rationale, disposition, reach,
|
|
240
|
+
tuple(effect.split(",")) if effect else (), added, direction)
|
|
241
|
+
for role, answer, rationale, disposition, reach, effect, added, direction
|
|
242
|
+
in zip(*fields, strict=True)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _add_open_arguments(parser: argparse.ArgumentParser) -> None:
|
|
247
|
+
required = (
|
|
248
|
+
"task-key", "task-type", "run-seq", "clarification-id", "ticket-id",
|
|
249
|
+
"statement", "expected-form", "classification", "origin",
|
|
250
|
+
"user-confirmation", "unblock-condition", "recommended-disposition",
|
|
251
|
+
)
|
|
252
|
+
for flag in required:
|
|
253
|
+
parser.add_argument(f"--{flag}", required=True)
|
|
254
|
+
repeated = (
|
|
255
|
+
"role", "answer", "rationale", "disposition", "reach",
|
|
256
|
+
"scope-effect", "added-work", "direction-change",
|
|
257
|
+
)
|
|
258
|
+
for flag in repeated:
|
|
259
|
+
parser.add_argument(f"--option-{flag}", action="append", default=[])
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _parser() -> argparse.ArgumentParser:
|
|
263
|
+
parser = argparse.ArgumentParser(prog="okstra approval-decision")
|
|
264
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
265
|
+
opened = commands.add_parser("open")
|
|
266
|
+
opened.add_argument("--ledger", type=Path, required=True)
|
|
267
|
+
_add_open_arguments(opened)
|
|
268
|
+
resolved = commands.add_parser("resolve")
|
|
269
|
+
resolved.add_argument("--ledger", type=Path, required=True)
|
|
270
|
+
resolved.add_argument("--clarification-id", required=True)
|
|
271
|
+
resolved.add_argument("--disposition", required=True)
|
|
272
|
+
resolved.add_argument("--user-text", required=True)
|
|
273
|
+
resolved.add_argument("--user-response-ref", required=True)
|
|
274
|
+
resolved.add_argument("--check-ref", action="append", default=[])
|
|
275
|
+
carried = commands.add_parser("carry")
|
|
276
|
+
carried.add_argument("--ledger", type=Path, required=True)
|
|
277
|
+
carried.add_argument("--source-ledger", type=Path, required=True)
|
|
278
|
+
carried.add_argument("--source-run-ref", required=True)
|
|
279
|
+
carried.add_argument("--clarification-id", required=True)
|
|
280
|
+
return parser
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _open_from_args(args: argparse.Namespace) -> None:
|
|
284
|
+
values = vars(args).copy()
|
|
285
|
+
values.pop("command")
|
|
286
|
+
values["ledger_path"] = values.pop("ledger")
|
|
287
|
+
for key in tuple(values):
|
|
288
|
+
if key.startswith("option_"):
|
|
289
|
+
values.pop(key)
|
|
290
|
+
open_decision(**values, options=_options_from_args(args))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _carry_from_args(args: argparse.Namespace) -> None:
|
|
294
|
+
source = _read_ledger(args.source_ledger)
|
|
295
|
+
rows = source.get("activeClarifications") or []
|
|
296
|
+
matches = [row for row in rows if isinstance(row, dict) and row.get("id") == args.clarification_id]
|
|
297
|
+
if len(matches) != 1:
|
|
298
|
+
raise ApprovalDecisionError(f"source clarification not found: {args.clarification_id}")
|
|
299
|
+
carry_decision(args.ledger, source_run_ref=args.source_run_ref, decision=matches[0])
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _run(args: argparse.Namespace) -> None:
|
|
303
|
+
if args.command == "open":
|
|
304
|
+
_open_from_args(args)
|
|
305
|
+
elif args.command == "resolve":
|
|
306
|
+
resolve_decision(
|
|
307
|
+
args.ledger, args.clarification_id, disposition=args.disposition,
|
|
308
|
+
user_text=args.user_text, user_response_ref=args.user_response_ref,
|
|
309
|
+
check_refs=args.check_ref,
|
|
310
|
+
)
|
|
311
|
+
else:
|
|
312
|
+
_carry_from_args(args)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def main(argv: list[str] | None = None) -> int:
|
|
316
|
+
try:
|
|
317
|
+
args = _parser().parse_args(argv)
|
|
318
|
+
_run(args)
|
|
319
|
+
print(json.dumps({"ok": True, "ledger": str(args.ledger)}, ensure_ascii=False))
|
|
320
|
+
return 0
|
|
321
|
+
except (ApprovalDecisionError, OSError) as exc:
|
|
322
|
+
print(f"approval-decision: {exc}", file=sys.stderr)
|
|
323
|
+
return 1
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""보고서 서사에서 설계 표면 탐지기 소유 스냅샷을 생성한다."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from .convergence_store import write_json_atomic
|
|
11
|
+
from .design_surfaces import DesignSurfaceError, detect_design_surfaces
|
|
12
|
+
from .final_report_schema import load_schema_version
|
|
13
|
+
from .report_narrative import NarrativeContractError, parse_narrative
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _evidence(trigger: Any) -> list[dict[str, Any]]:
|
|
17
|
+
return [
|
|
18
|
+
{"step": row.step, "field": row.field, "match": row.match}
|
|
19
|
+
for row in trigger.evidence
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_design_snapshot(planning: Mapping[str, Any]) -> dict[str, Any]:
|
|
24
|
+
"""계획 본문에서 재현 가능한 설계 표면과 보수적 준비 항목을 만든다."""
|
|
25
|
+
triggers = detect_design_surfaces(planning)
|
|
26
|
+
by_stage: dict[int, list[dict[str, Any]]] = {}
|
|
27
|
+
items = []
|
|
28
|
+
for index, trigger in enumerate(triggers, start=1):
|
|
29
|
+
evidence = _evidence(trigger)
|
|
30
|
+
prep_id = f"PREP-{index:03d}"
|
|
31
|
+
evidence_refs = [
|
|
32
|
+
f"Stage {trigger.stage} step {row['step'] or 'n/a'} "
|
|
33
|
+
f"{row['field']}={row['match']}"
|
|
34
|
+
for row in evidence
|
|
35
|
+
]
|
|
36
|
+
by_stage.setdefault(trigger.stage, []).append({
|
|
37
|
+
"kind": trigger.kind,
|
|
38
|
+
"triggerEvidence": evidence,
|
|
39
|
+
"disposition": "prep-item",
|
|
40
|
+
"prepItemId": prep_id,
|
|
41
|
+
})
|
|
42
|
+
items.append({
|
|
43
|
+
"id": prep_id,
|
|
44
|
+
"kind": trigger.kind,
|
|
45
|
+
"title": f"Stage {trigger.stage} {trigger.kind} contract",
|
|
46
|
+
"stageRefs": [trigger.stage],
|
|
47
|
+
"status": "provisional",
|
|
48
|
+
"need": (
|
|
49
|
+
f"Stage {trigger.stage} touches a {trigger.kind} surface, but the "
|
|
50
|
+
"detector evidence does not define its implementation contract."
|
|
51
|
+
),
|
|
52
|
+
"knownFacts": [
|
|
53
|
+
{"statement": "The plan triggered this design surface.", "evidence": ref}
|
|
54
|
+
for ref in evidence_refs
|
|
55
|
+
],
|
|
56
|
+
"openQuestions": [
|
|
57
|
+
f"What exact {trigger.kind} contract must Stage {trigger.stage} implement?"
|
|
58
|
+
],
|
|
59
|
+
"aiProposal": {
|
|
60
|
+
"summary": "Preserve existing behaviour until the contract is confirmed.",
|
|
61
|
+
"details": [
|
|
62
|
+
"Record the concrete contract before an irreversible implementation step."
|
|
63
|
+
],
|
|
64
|
+
"assumptions": [
|
|
65
|
+
"Detector evidence proves surface presence, not the missing contract details."
|
|
66
|
+
],
|
|
67
|
+
"evidence": evidence_refs,
|
|
68
|
+
"confidence": "low",
|
|
69
|
+
},
|
|
70
|
+
"humanConfirmation": {
|
|
71
|
+
"required": True,
|
|
72
|
+
"reason": "The plan does not contain enough evidence to infer the contract.",
|
|
73
|
+
"suggestedAction": "confirm-or-edit",
|
|
74
|
+
},
|
|
75
|
+
"workingAssumption": "Preserve existing behaviour and avoid irreversible changes.",
|
|
76
|
+
"guardrails": [
|
|
77
|
+
"Do not present a synthetic or inferred contract as externally verified."
|
|
78
|
+
],
|
|
79
|
+
"reviewAt": {"phase": "implementation", "stage": trigger.stage},
|
|
80
|
+
"ifStillOpen": "block",
|
|
81
|
+
"requestPath": (
|
|
82
|
+
f"design-prep-requests/design-prep-request-{trigger.stage}-{prep_id}.md"
|
|
83
|
+
),
|
|
84
|
+
"replanTriggerFields": ["aiProposal", "workingAssumption"],
|
|
85
|
+
})
|
|
86
|
+
preparation = {
|
|
87
|
+
"mode": "assessed" if triggers else "no-design-inputs",
|
|
88
|
+
"reason": (
|
|
89
|
+
f"Detector found {len(triggers)} design surface(s)."
|
|
90
|
+
if triggers else "Detector found no design surfaces in the plan."
|
|
91
|
+
),
|
|
92
|
+
"items": items,
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
"schemaVersion": "1.0",
|
|
96
|
+
"owner": "design-surface-detector",
|
|
97
|
+
"designPreparation": preparation,
|
|
98
|
+
"stageCoverage": [
|
|
99
|
+
{"stage": stage, "rows": rows}
|
|
100
|
+
for stage, rows in sorted(by_stage.items())
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def write_snapshot(narrative_path: Path, output_path: Path) -> dict[str, Any]:
|
|
106
|
+
narrative = parse_narrative(
|
|
107
|
+
narrative_path.read_text(encoding="utf-8"), load_schema_version("3.0")
|
|
108
|
+
)
|
|
109
|
+
planning = narrative.get("implementationPlanning")
|
|
110
|
+
if not isinstance(planning, Mapping):
|
|
111
|
+
raise DesignSurfaceError("implementationPlanning is missing from narrative")
|
|
112
|
+
snapshot = build_design_snapshot(planning)
|
|
113
|
+
write_json_atomic(output_path, snapshot)
|
|
114
|
+
return snapshot
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main(argv: list[str] | None = None) -> int:
|
|
118
|
+
parser = argparse.ArgumentParser(prog="okstra design-snapshot")
|
|
119
|
+
parser.add_argument("--narrative", type=Path, required=True)
|
|
120
|
+
parser.add_argument("--output", type=Path, required=True)
|
|
121
|
+
args = parser.parse_args(argv)
|
|
122
|
+
try:
|
|
123
|
+
snapshot = write_snapshot(args.narrative, args.output)
|
|
124
|
+
except (OSError, UnicodeError, DesignSurfaceError, NarrativeContractError) as exc:
|
|
125
|
+
print(f"design-snapshot: {exc}", file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
print(json.dumps({"ok": True, "surfaces": sum(
|
|
128
|
+
len(row["rows"]) for row in snapshot["stageCoverage"]
|
|
129
|
+
)}, ensure_ascii=False))
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
raise SystemExit(main())
|
|
@@ -310,7 +310,7 @@ class DispatchPlan:
|
|
|
310
310
|
@dataclass(frozen=True)
|
|
311
311
|
class _BuildOptions:
|
|
312
312
|
okstra_bin: Path | None
|
|
313
|
-
idle_timeout_seconds: int
|
|
313
|
+
idle_timeout_seconds: int | None
|
|
314
314
|
default_backend: str
|
|
315
315
|
supported_worker_wrappers: Mapping[str, str]
|
|
316
316
|
unsupported_worker_label: str
|
|
@@ -335,7 +335,7 @@ def build_dispatch_plan(
|
|
|
335
335
|
workspace_root: Path,
|
|
336
336
|
okstra_bin: Path | None = None,
|
|
337
337
|
requested_workers: Sequence[str] = (),
|
|
338
|
-
idle_timeout_seconds: int =
|
|
338
|
+
idle_timeout_seconds: int | None = None,
|
|
339
339
|
required_lead_runtime: str | None = None,
|
|
340
340
|
default_backend: str = BACKEND_CLI_WRAPPER,
|
|
341
341
|
supported_worker_wrappers: Mapping[str, str],
|
|
@@ -432,6 +432,37 @@ def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
|
432
432
|
return 0
|
|
433
433
|
|
|
434
434
|
|
|
435
|
+
def _orchestrator_artifact_roots(plan: DispatchPlan) -> tuple[Path, ...]:
|
|
436
|
+
"""okstra 자신이 이 run 동안 쓰는 산출물 경로.
|
|
437
|
+
|
|
438
|
+
`_round_artifact_paths` 는 이번 배치의 job 만 알기 때문에 원리상 완결될 수
|
|
439
|
+
없다. 감사 창에 들어오는 것은 형제 job 의 산출물만이 아니라 같은 run 의 다른
|
|
440
|
+
라운드가 남기는 것들이다 — 재시도 프롬프트와 그 `.meta.json` / `.publish.lock`,
|
|
441
|
+
이전 라운드 워커의 `.log` / `.status.json` / `.mutation-audit.json`, critic 의
|
|
442
|
+
결과, design-prep 요청. 실측 한 건에서 그렇게 빠져나간 경로가 16개였고 그
|
|
443
|
+
배치의 디스패치 5건이 전부 `readonly source changed` 로 떨어졌다.
|
|
444
|
+
|
|
445
|
+
그래서 파일을 더 세는 대신 트리를 싣는다. 이 디렉터리 아래는 정의상 okstra 의
|
|
446
|
+
산출물이고 프로젝트 소스가 아니다 — 소스가 바뀌었는지를 묻는 질문의 답이 될 수
|
|
447
|
+
없다.
|
|
448
|
+
"""
|
|
449
|
+
# 실행 매니페스트는 `<run_dir>/manifests/` 아래 놓인다.
|
|
450
|
+
roots = [plan.manifest_path.resolve().parents[1]]
|
|
451
|
+
# task 매니페스트와 discovery 카탈로그는 run 디렉터리 위에 있고, prep 과
|
|
452
|
+
# 완료 기록이 실행 중에 갱신한다. 경로는 매니페스트가 이미 들고 있으므로
|
|
453
|
+
# 디렉터리 깊이를 세지 않는다 — implementation 은 stage 층이 하나 더 있어
|
|
454
|
+
# 고정 인덱스로는 task root 에 닿지 못한다.
|
|
455
|
+
for key in ("taskManifestPath", "taskCatalogPath"):
|
|
456
|
+
value = plan.manifest.get(key)
|
|
457
|
+
if isinstance(value, str) and value:
|
|
458
|
+
candidate = Path(value)
|
|
459
|
+
roots.append(
|
|
460
|
+
candidate if candidate.is_absolute()
|
|
461
|
+
else (plan.project_root / candidate)
|
|
462
|
+
)
|
|
463
|
+
return tuple(roots)
|
|
464
|
+
|
|
465
|
+
|
|
435
466
|
def _round_artifact_paths(plan: DispatchPlan) -> tuple[Path, ...]:
|
|
436
467
|
"""Every artifact this round's workers are entitled to write.
|
|
437
468
|
|
|
@@ -1488,6 +1519,7 @@ def _mutation_snapshot(
|
|
|
1488
1519
|
orchestrator_paths=(
|
|
1489
1520
|
*round_artifact_paths,
|
|
1490
1521
|
*_run_errors_log_path(plan),
|
|
1522
|
+
*_orchestrator_artifact_roots(plan),
|
|
1491
1523
|
plan.manifest_path,
|
|
1492
1524
|
plan.team_state_path,
|
|
1493
1525
|
Path(f"{plan.team_state_path}.lock"),
|
|
@@ -1840,6 +1872,31 @@ def _retry_from_record(
|
|
|
1840
1872
|
_spawn_job(plan, _job_for_next_attempt(job), attempt + 1)
|
|
1841
1873
|
|
|
1842
1874
|
|
|
1875
|
+
# 사유 한 줄에 실을 경로 개수. 전부 실으면 team-state 행이 부풀고, 하나도 안
|
|
1876
|
+
# 실으면 읽는 쪽이 다른 파일을 뒤져야 한다.
|
|
1877
|
+
_MUTATION_REASON_PATH_LIMIT = 5
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
def _mutation_failure_reason(mutation) -> str:
|
|
1881
|
+
"""왜 실패했는지와 무엇이 바뀌었는지를 한 줄에 담는다.
|
|
1882
|
+
|
|
1883
|
+
종전에는 디스패치 행에 `readonly source changed` 만 남고 바뀐 경로는
|
|
1884
|
+
lead-events 에만 있었다. 읽는 쪽이 그 파일의 존재를 알아야 원인에 닿을 수
|
|
1885
|
+
있었고, 실제로 두 번의 진단이 같은 자리에서 빗나갔다 — 한 번은 감사를 손으로
|
|
1886
|
+
재구현해서, 한 번은 사용자 편집을 의심해서. 감사는 답을 이미 계산해 두었으니
|
|
1887
|
+
사유 옆에 놓는다.
|
|
1888
|
+
"""
|
|
1889
|
+
reason = "; ".join(mutation.violations) or mutation.status
|
|
1890
|
+
paths = list(mutation.changed_paths)
|
|
1891
|
+
if not paths:
|
|
1892
|
+
return reason
|
|
1893
|
+
shown = ", ".join(paths[:_MUTATION_REASON_PATH_LIMIT])
|
|
1894
|
+
remaining = len(paths) - _MUTATION_REASON_PATH_LIMIT
|
|
1895
|
+
if remaining > 0:
|
|
1896
|
+
shown += f", +{remaining} more"
|
|
1897
|
+
return f"{reason} [changed: {shown}]"
|
|
1898
|
+
|
|
1899
|
+
|
|
1843
1900
|
def _finish_attempt(
|
|
1844
1901
|
plan: DispatchPlan,
|
|
1845
1902
|
job: WorkerJob,
|
|
@@ -1854,7 +1911,7 @@ def _finish_attempt(
|
|
|
1854
1911
|
"contract-failed-unattributed",
|
|
1855
1912
|
"mutation-present-unresolved",
|
|
1856
1913
|
}:
|
|
1857
|
-
reason =
|
|
1914
|
+
reason = _mutation_failure_reason(mutation)
|
|
1858
1915
|
_transition_job_status(plan, job, "error", reason)
|
|
1859
1916
|
_update_dispatch_status(
|
|
1860
1917
|
plan.team_state_path, job, attempt, "error", reason
|
|
@@ -2866,7 +2923,8 @@ def _job_from_record(project_root: Path, record: Mapping[str, Any]) -> WorkerJob
|
|
|
2866
2923
|
completion_paths=tuple(_record_completion_paths(record)),
|
|
2867
2924
|
worktree_path=_string_value(record.get("worktreePath")),
|
|
2868
2925
|
role=_require_string(record, "role"),
|
|
2869
|
-
|
|
2926
|
+
# 복원된 job 은 다시 실행되지 않으므로 예산을 정하지 않는다.
|
|
2927
|
+
idle_timeout_seconds=None,
|
|
2870
2928
|
dispatch_kind=_require_string(record, "kind"),
|
|
2871
2929
|
# Restored, never re-issued: both callers rebuild a job from its record
|
|
2872
2930
|
# to settle a dispatch that already ran, and a fresh id would settle it
|
|
@@ -47,6 +47,7 @@ from .execution_manifest import (
|
|
|
47
47
|
)
|
|
48
48
|
from .execution_mutation_audit import ExecutionMutationAudit, MutationSnapshot
|
|
49
49
|
from .final_report_paths import final_report_data_path
|
|
50
|
+
from .report_inputs import report_narrative_path, uses_report_contract_v3
|
|
50
51
|
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
51
52
|
from .worker_prompt_contract import (
|
|
52
53
|
PromptRecord,
|
|
@@ -152,7 +153,11 @@ class WorkerJob:
|
|
|
152
153
|
completion_paths: tuple[Path, ...]
|
|
153
154
|
worktree_path: str
|
|
154
155
|
role: str
|
|
155
|
-
|
|
156
|
+
# None 은 "이 디스패치가 예산을 정하지 않는다" 는 뜻이고, 그때 예산은
|
|
157
|
+
# 역할이 정한다(`domain/worker_role.role_spec`). 여기에 숫자 기본값을 두면
|
|
158
|
+
# 역할별 예산이 영영 도달 불가능해진다 — 엔트리포인트는 이 자리가 비었을
|
|
159
|
+
# 때만 역할을 보기 때문이다.
|
|
160
|
+
idle_timeout_seconds: int | None
|
|
156
161
|
dispatch_kind: str
|
|
157
162
|
invocation_id: str = ""
|
|
158
163
|
audience: str = ""
|
|
@@ -188,7 +193,7 @@ class WorkerJob:
|
|
|
188
193
|
str(self.prompt_path),
|
|
189
194
|
self.worktree_path,
|
|
190
195
|
self.wrapper_role,
|
|
191
|
-
|
|
196
|
+
self._idle_timeout_argument,
|
|
192
197
|
"--presentation",
|
|
193
198
|
self._presentation(),
|
|
194
199
|
]
|
|
@@ -201,6 +206,18 @@ class WorkerJob:
|
|
|
201
206
|
argv += ["--session-id", self.session_id]
|
|
202
207
|
return argv
|
|
203
208
|
|
|
209
|
+
@property
|
|
210
|
+
def _idle_timeout_argument(self) -> str:
|
|
211
|
+
"""엔트리포인트가 읽는 유휴 예산 자리.
|
|
212
|
+
|
|
213
|
+
비워 두면 엔트리포인트가 역할의 예산을 쓴다. 이 자리를 항상 채우던
|
|
214
|
+
동안에는 `worker_request.idle_timeout` 의 역할 분기가 한 번도 실행되지
|
|
215
|
+
않았고, executor/verifier 의 1500s 도 함께 죽어 있었다.
|
|
216
|
+
"""
|
|
217
|
+
if self.idle_timeout_seconds is None:
|
|
218
|
+
return ""
|
|
219
|
+
return str(self.idle_timeout_seconds)
|
|
220
|
+
|
|
204
221
|
@property
|
|
205
222
|
def wrapper_role(self) -> str:
|
|
206
223
|
"""The canonical role the entrypoint's role positional is read as.
|
|
@@ -707,6 +724,10 @@ def record_verified_agent_dispatch(
|
|
|
707
724
|
snapshot = ExecutionMutationAudit().snapshot(
|
|
708
725
|
(write_policy,),
|
|
709
726
|
orchestrator_paths=(
|
|
727
|
+
# run 산출물 트리 전체. 같은 run 의 다른 라운드가 감사 창
|
|
728
|
+
# 안에 남기는 프롬프트·로그·사이드카를 파일 단위로 미리 셀
|
|
729
|
+
# 수 없고, 그 아래는 정의상 소스가 아니다.
|
|
730
|
+
Path(run_manifest_path).resolve().parents[1],
|
|
710
731
|
run_manifest_path,
|
|
711
732
|
team_state_path,
|
|
712
733
|
Path(f"{team_state_path}.lock"),
|
|
@@ -1468,6 +1489,8 @@ def dispatch_result_path(
|
|
|
1468
1489
|
"""
|
|
1469
1490
|
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
1470
1491
|
return worker_result_path
|
|
1492
|
+
if uses_report_contract_v3(manifest):
|
|
1493
|
+
return report_narrative_path(project_root, manifest)
|
|
1471
1494
|
return final_report_data_path(
|
|
1472
1495
|
resolve_required_path(project_root, manifest, "expectedReportRecordPath")
|
|
1473
1496
|
)
|
|
@@ -1487,6 +1510,8 @@ def dispatch_completion_paths(
|
|
|
1487
1510
|
"""
|
|
1488
1511
|
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
1489
1512
|
return (worker_result_path,)
|
|
1513
|
+
if uses_report_contract_v3(manifest):
|
|
1514
|
+
return (report_narrative_path(project_root, manifest), worker_result_path)
|
|
1490
1515
|
data_json = final_report_data_path(
|
|
1491
1516
|
resolve_required_path(project_root, manifest, "expectedReportRecordPath")
|
|
1492
1517
|
)
|
|
@@ -1765,7 +1790,7 @@ def worker_jobs_from_file(
|
|
|
1765
1790
|
manifest: Mapping[str, Any],
|
|
1766
1791
|
active_context: Mapping[str, Any] | None = None,
|
|
1767
1792
|
backend: str,
|
|
1768
|
-
idle_timeout_seconds: int,
|
|
1793
|
+
idle_timeout_seconds: int | None,
|
|
1769
1794
|
default_dispatch_kind: str,
|
|
1770
1795
|
resolve_wrapper: Callable[[str], Path],
|
|
1771
1796
|
default_provider: Callable[[str], str],
|
|
@@ -1804,7 +1829,7 @@ def _worker_job_from_file(
|
|
|
1804
1829
|
manifest: Mapping[str, Any],
|
|
1805
1830
|
active_context: Mapping[str, Any] | None = None,
|
|
1806
1831
|
backend: str,
|
|
1807
|
-
idle_timeout_seconds: int,
|
|
1832
|
+
idle_timeout_seconds: int | None,
|
|
1808
1833
|
dispatch_kind: str,
|
|
1809
1834
|
resolve_wrapper: Callable[[str], Path],
|
|
1810
1835
|
default_provider: Callable[[str], str],
|