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.
Files changed (78) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +14 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/incremental-carry.mjs +9 -8
  5. package/dist/commands/execute/incremental-carry.mjs.map +1 -1
  6. package/dist/commands/execute/plan-verify.mjs +3 -1
  7. package/dist/commands/execute/plan-verify.mjs.map +1 -1
  8. package/dist/commands/report/approval-decision.d.mts +1 -0
  9. package/dist/commands/report/approval-decision.mjs +21 -0
  10. package/dist/commands/report/approval-decision.mjs.map +1 -0
  11. package/dist/commands/report/design-snapshot.d.mts +1 -0
  12. package/dist/commands/report/design-snapshot.mjs +19 -0
  13. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  14. package/docs/architecture/storage-model.md +1 -1
  15. package/docs/architecture.md +10 -10
  16. package/docs/cli.md +11 -8
  17. package/docs/project-structure-overview.md +15 -6
  18. package/docs/task-process/implementation-planning.md +2 -2
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/agents/workers/report-writer-worker.md +15 -164
  22. package/runtime/prompts/launch.template.md +6 -5
  23. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  24. package/runtime/prompts/lead/convergence.md +2 -2
  25. package/runtime/prompts/lead/okstra-lead-contract.md +19 -18
  26. package/runtime/prompts/lead/plan-body-verification.md +39 -18
  27. package/runtime/prompts/lead/report-writer.md +64 -423
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  30. package/runtime/prompts/profiles/_common-contract.md +3 -3
  31. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  32. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  33. package/runtime/prompts/profiles/error-analysis.md +1 -1
  34. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  35. package/runtime/prompts/profiles/implementation-planning.md +13 -11
  36. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  37. package/runtime/prompts/profiles/project-analysis.md +1 -1
  38. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  39. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -1
  40. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  41. package/runtime/python/okstra_ctl/agent_activity.py +23 -3
  42. package/runtime/python/okstra_ctl/agent_prompt_cli.py +6 -6
  43. package/runtime/python/okstra_ctl/analysis_packet.py +43 -2
  44. package/runtime/python/okstra_ctl/approval_decisions.py +327 -0
  45. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  46. package/runtime/python/okstra_ctl/dispatch_core.py +62 -4
  47. package/runtime/python/okstra_ctl/dispatch_state.py +29 -4
  48. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  49. package/runtime/python/okstra_ctl/final_report_schema.py +24 -15
  50. package/runtime/python/okstra_ctl/incremental_carry.py +128 -16
  51. package/runtime/python/okstra_ctl/incremental_scope.py +4 -1
  52. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  53. package/runtime/python/okstra_ctl/paths.py +12 -0
  54. package/runtime/python/okstra_ctl/plan_items_cli.py +113 -16
  55. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  56. package/runtime/python/okstra_ctl/render.py +48 -1
  57. package/runtime/python/okstra_ctl/render_final_report.py +7 -6
  58. package/runtime/python/okstra_ctl/report_assembly.py +354 -0
  59. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  60. package/runtime/python/okstra_ctl/report_finalize.py +60 -22
  61. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  62. package/runtime/python/okstra_ctl/report_markdown.py +69 -8
  63. package/runtime/python/okstra_ctl/report_narrative.py +319 -0
  64. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  65. package/runtime/python/okstra_ctl/run.py +25 -9
  66. package/runtime/python/okstra_ctl/schema_excerpt.py +11 -6
  67. package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -4
  68. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  69. package/runtime/python/okstra_ctl/stage_map.py +70 -22
  70. package/runtime/python/okstra_ctl/team.py +1 -1
  71. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  72. package/runtime/python/okstra_ctl/worker_prompt_body.py +35 -0
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  74. package/runtime/schemas/final-report-v3.0.schema.json +10210 -0
  75. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  76. package/runtime/templates/report-writer-prompt-preamble.md +15 -21
  77. package/runtime/templates/reports/html/macros/forms.html +6 -4
  78. 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 = 600,
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 = "; ".join(mutation.violations) or mutation.status
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
- idle_timeout_seconds=600,
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
- idle_timeout_seconds: int
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
- str(self.idle_timeout_seconds),
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],