okstra 0.200.0 → 0.201.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 (96) hide show
  1. package/README.md +4 -2
  2. package/dist/cli-registry.mjs +6 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/docs/cli.md +14 -3
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +7 -3
  8. package/runtime/bin/okstra-spawn-followups.py +2 -2
  9. package/runtime/prompts/duties/technical-verification-worker.md +44 -0
  10. package/runtime/prompts/launch.template.md +7 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
  12. package/runtime/prompts/lead/plan-body-verification.md +3 -1
  13. package/runtime/prompts/lead/report-writer.md +11 -5
  14. package/runtime/prompts/lead/team-contract.md +6 -0
  15. package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
  16. package/runtime/prompts/profiles/final-verification.md +5 -0
  17. package/runtime/prompts/profiles/forbidden-actions.json +6 -0
  18. package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
  19. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  20. package/runtime/prompts/profiles/technical-verification.md +53 -0
  21. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  22. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
  23. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
  24. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
  25. package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
  26. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
  27. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
  28. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
  29. package/runtime/python/okstra_ctl/conformance.py +2 -20
  30. package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
  31. package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
  32. package/runtime/python/okstra_ctl/domain/provider.py +0 -1
  33. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  34. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
  35. package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
  36. package/runtime/python/okstra_ctl/implementation_options.py +58 -45
  37. package/runtime/python/okstra_ctl/model_pool.py +2 -5
  38. package/runtime/python/okstra_ctl/next_phase.py +3 -0
  39. package/runtime/python/okstra_ctl/plan_items.py +15 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
  41. package/runtime/python/okstra_ctl/qa_commands.py +30 -0
  42. package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
  43. package/runtime/python/okstra_ctl/render.py +3 -0
  44. package/runtime/python/okstra_ctl/render_final_report.py +1 -0
  45. package/runtime/python/okstra_ctl/report_assembly.py +8 -2
  46. package/runtime/python/okstra_ctl/report_contract.py +3 -0
  47. package/runtime/python/okstra_ctl/report_corrections.py +209 -93
  48. package/runtime/python/okstra_ctl/report_finalize.py +25 -8
  49. package/runtime/python/okstra_ctl/report_html/router.py +2 -0
  50. package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
  51. package/runtime/python/okstra_ctl/report_projections.py +4 -3
  52. package/runtime/python/okstra_ctl/report_synthesis_packet.py +181 -47
  53. package/runtime/python/okstra_ctl/run.py +82 -0
  54. package/runtime/python/okstra_ctl/team.py +4 -1
  55. package/runtime/python/okstra_ctl/technical_verification.py +195 -0
  56. package/runtime/python/okstra_ctl/usage_identity.py +54 -0
  57. package/runtime/python/okstra_ctl/usage_report.py +22 -8
  58. package/runtime/python/okstra_ctl/verification_target.py +74 -0
  59. package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
  60. package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
  61. package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
  62. package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
  63. package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
  64. package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
  65. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
  66. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  67. package/runtime/python/okstra_ctl/wizard/render.py +8 -55
  68. package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
  69. package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
  70. package/runtime/python/okstra_ctl/wizard/state.py +13 -6
  71. package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
  72. package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  74. package/runtime/python/okstra_ctl/workflow.py +8 -0
  75. package/runtime/python/okstra_ctl/write_policy.py +23 -0
  76. package/runtime/python/okstra_token_usage/blocks.py +50 -1
  77. package/runtime/python/okstra_token_usage/claude.py +42 -21
  78. package/runtime/python/okstra_token_usage/codex.py +17 -0
  79. package/runtime/python/okstra_token_usage/collect.py +299 -162
  80. package/runtime/python/okstra_token_usage/cursor.py +2 -3
  81. package/runtime/python/okstra_token_usage/report.py +35 -30
  82. package/runtime/python/okstra_token_usage/task_totals.py +3 -12
  83. package/runtime/schemas/final-report-v2.0.schema.json +298 -7
  84. package/runtime/schemas/final-report-v3.0.schema.json +298 -7
  85. package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
  86. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +1 -1
  87. package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
  88. package/runtime/skills/okstra-run/SKILL.md +10 -2
  89. package/runtime/skills/okstra-setup/SKILL.md +42 -7
  90. package/runtime/templates/report-writer-prompt-preamble.md +7 -3
  91. package/runtime/templates/reports/html/i18n/en.json +11 -0
  92. package/runtime/templates/reports/html/i18n/ko.json +11 -0
  93. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
  94. package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
  95. package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
  96. package/runtime/validators/validate-run.py +9 -4
@@ -26,6 +26,7 @@ from .report_synthesis_packet import (
26
26
  from .conformance import strip_stage_declaration_label
27
27
  from .design_prep import DesignPrepError, materialize_design_prep_requests
28
28
  from .implementation_options import validate_blocked_answer_channel
29
+ from .technical_verification import validate_technical_verification_report
29
30
  from .implementation_direction import (
30
31
  load_selected_direction_snapshot,
31
32
  validate_selected_direction_plan,
@@ -462,8 +463,7 @@ def _compose(
462
463
  data["tokenUsage"] = usage
463
464
  data["crossVerification"] = convergence_data["crossVerification"]
464
465
  data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
465
- # 차단된 option-selection 발행 전에 답변 채널을 갖춰야 한다. 발행 뒤에는
466
- # 이 원장을 다시 읽는 경로가 없어 사용자에게 질문이 0건으로 보인다.
466
+ # 작성자의 사실 분류와 리드 원장의 질문 연결은 양쪽 입력을 합친 뒤 검사한다.
467
467
  for reason in validate_blocked_answer_channel(data):
468
468
  _fail(
469
469
  "lead",
@@ -888,6 +888,12 @@ def assemble_report(
888
888
  _fail("orchestrator", manifest_path, "expectedReportRecordPath", "required")
889
889
  target = Path(value)
890
890
  target = target if target.is_absolute() else project_root / target
891
+ verification_errors = validate_technical_verification_report(data, target, project_root)
892
+ if verification_errors:
893
+ raise ReportAssemblyError(tuple(
894
+ AssemblyIssue("report-writer", str(target), "technicalVerification", error)
895
+ for error in verification_errors
896
+ ))
891
897
  _publish(target, data, replace)
892
898
  if errors or direction_errors:
893
899
  raise ReportAssemblyError(
@@ -9,6 +9,7 @@ PUBLIC_REPORT_TASK_TYPES = (
9
9
  "requirements-discovery",
10
10
  "improvement-discovery",
11
11
  "error-analysis",
12
+ "technical-verification",
12
13
  "implementation-option-selection",
13
14
  "project-analysis",
14
15
  "feature-analysis",
@@ -23,6 +24,7 @@ TASK_TYPE_DATA_PROPERTY = {
23
24
  "requirements-discovery": "requirementsDiscovery",
24
25
  "improvement-discovery": "improvementDiscovery",
25
26
  "error-analysis": "errorAnalysis",
27
+ "technical-verification": "technicalVerification",
26
28
  "implementation-option-selection": "implementationOptionSelection",
27
29
  "project-analysis": "projectAnalysis",
28
30
  "feature-analysis": "featureAnalysis",
@@ -47,6 +49,7 @@ TASK_TYPE_MARKDOWN_TEMPLATE = {
47
49
  }
48
50
 
49
51
  TASK_TYPE_REQUIRED_HUMAN_FIELDS = {
52
+ "technical-verification": ("technicalVerification.sourceReport", "technicalVerification.checks", "technicalVerification.routing"),
50
53
  "requirements-discovery": (
51
54
  "requirementsDiscovery.requestVerbatim",
52
55
  "requirementsDiscovery.systemInterpretation",
@@ -13,6 +13,7 @@
13
13
  """
14
14
  from __future__ import annotations
15
15
 
16
+ import hashlib
16
17
  import json
17
18
  import re
18
19
  from copy import deepcopy
@@ -33,8 +34,8 @@ from .report_narrative import (
33
34
  )
34
35
 
35
36
 
36
- CORRECTION_KINDS = ("replace", "remove", "rewrite")
37
- MECHANICAL_KINDS = frozenset({"replace", "remove"})
37
+ CORRECTION_KINDS = ("replace", "remove", "add", "move", "rewrite")
38
+ MECHANICAL_KINDS = frozenset({"replace", "remove", "add", "move"})
38
39
  # okstra 가 렌더하는 절. 지시문 본문에 있으면 두 절이 갈라지므로 거절한다.
39
40
  OKSTRA_OWNED_SECTIONS = ("## Corrections", "## Previous Attempt", "## Output")
40
41
 
@@ -57,7 +58,7 @@ class CorrectionsCheck:
57
58
 
58
59
  ledger: dict[str, Any]
59
60
  corrections: tuple[dict[str, Any], ...]
60
- # 기계적 교정(replace·remove)을 적용한 서사 자료. 기준 서사를 못 읽었으면 None.
61
+ # 확정 값의 교정을 적용한 서사 자료. 기준 서사를 못 읽었으면 None.
61
62
  scratch: dict[str, Any] | None
62
63
  # 작성자 라운드 없이 끝나는가 — rewrite 가 없고 결함도 없다.
63
64
  mechanical: bool
@@ -156,8 +157,8 @@ def _set(data: Any, tokens: Sequence[str | int], value: Any) -> None:
156
157
  if not found:
157
158
  raise KeyError(tokens)
158
159
  last = tokens[-1]
159
- if isinstance(last, int):
160
- parent[last] = value
160
+ if isinstance(last, int) and last == len(parent):
161
+ parent.append(value)
161
162
  else:
162
163
  parent[last] = value
163
164
 
@@ -194,6 +195,7 @@ def check_corrections(
194
195
  schema: Mapping[str, Any],
195
196
  block_rules: Sequence[str] = (),
196
197
  semantic_validator: Callable[[dict[str, Any]], Sequence[str]] | None = None,
198
+ rewrite_results: Mapping[str, Any] | None = None,
197
199
  ) -> CorrectionsCheck:
198
200
  """원장을 기준 서사에 대조한다. 설계 §4.2 의 1~6단계.
199
201
 
@@ -204,11 +206,49 @@ def check_corrections(
204
206
  적용 뒤에도 남아 있는 것만 보고한다.
205
207
  3. 경로 해소와 `current` 일치. `rewrite` 는 배열의 `[len]` 추가 위치를 허용한다.
206
208
  4. 작성자 소유 경로인가.
207
- 5. replace·remove 적용한 사본을 작성자 소유 값 스키마와 task 의미 검증기로
209
+ 5. 확정 값의 교정을 적용한 사본을 작성자 소유 값 스키마와 task 의미 검증기로
208
210
  대조한다. rewrite 경로 아래로 특정된 결함은 뺀다(작성자가 다시 쓸 자리다).
209
211
  6. rewrite 마다 그 경로의 스키마 제약 문장을 붙인다.
210
212
  """
211
- ledger_dict = dict(ledger)
213
+ ledger_dict = deepcopy(dict(ledger))
214
+ digest = hashlib.sha256(base_narrative.encode("utf-8")).hexdigest()
215
+ expected_digest = ledger_dict.get("baseNarrativeSha256")
216
+ if expected_digest is not None and expected_digest != digest:
217
+ return CorrectionsCheck(ledger_dict, (), None, False, {}, (
218
+ _defect("ledger", "baseNarrativeSha256", "base narrative hash does not match"),
219
+ ))
220
+ ledger_dict["baseNarrativeSha256"] = digest
221
+ corrections, parsed, defects = _parse_corrections(ledger_dict)
222
+ try:
223
+ data, _base_defects = parse_narrative_structure(base_narrative, schema)
224
+ except NarrativeContractError as exc:
225
+ defects.append(_defect("ledger", str(ledger_dict.get("baseNarrativePath") or "-"),
226
+ f"base narrative does not parse: {exc}"))
227
+ return CorrectionsCheck(ledger_dict, corrections, None, False, {}, tuple(defects))
228
+ if rewrite_results is not None:
229
+ ledger_dict, result_defects = _complete_rewrites(ledger_dict, rewrite_results, data)
230
+ defects.extend(result_defects)
231
+ corrections = tuple(ledger_dict["corrections"])
232
+ valid, path_defects = _checked_correction_paths(corrections, parsed, data)
233
+ defects.extend(path_defects)
234
+ scratch = _apply_mechanical_corrections(data, valid, parsed)
235
+ rewrites = [item for item in valid if item.get("kind") == "rewrite"]
236
+ derived = _recount_planning_steps(scratch, corrections, rewrites)
237
+ corrections = (*corrections, *derived)
238
+ ledger_dict["corrections"] = list(corrections)
239
+ constraints = {str(item["id"]): _constraints_for(str(item["path"]), block_rules, schema)
240
+ for item in rewrites}
241
+ errors = validate_writer_owned(scratch, schema)
242
+ if semantic_validator is not None:
243
+ errors.extend(semantic_validator(scratch))
244
+ defects.extend(_remaining_correction_defects(errors, rewrites))
245
+ return CorrectionsCheck(ledger_dict, corrections, scratch, not defects and not rewrites,
246
+ constraints, tuple(defects))
247
+
248
+
249
+ def _parse_corrections(
250
+ ledger_dict: dict[str, Any],
251
+ ) -> tuple[tuple[dict[str, Any], ...], dict[str, tuple[str | int, ...]], list[str]]:
212
252
  defects: list[str] = [
213
253
  _defect("ledger", error.split(": ", 1)[0], error)
214
254
  for error in validate(ledger_dict, corrections_schema())
@@ -231,21 +271,16 @@ def check_corrections(
231
271
  parsed[correction_id] = parse_field_path(path)
232
272
  except ValueError as exc:
233
273
  defects.append(_defect(correction_id, path, str(exc)))
234
- if defects and not parsed:
235
- return CorrectionsCheck(ledger_dict, corrections, None, False, {}, tuple(defects))
274
+ return corrections, parsed, defects
236
275
 
237
- try:
238
- data, _base_defects = parse_narrative_structure(base_narrative, schema)
239
- except NarrativeContractError as exc:
240
- defects.append(_defect(
241
- "ledger", str(ledger_dict.get("baseNarrativePath") or "-"),
242
- f"base narrative does not parse: {exc}",
243
- ))
244
- return CorrectionsCheck(ledger_dict, corrections, None, False, {}, tuple(defects))
245
276
 
246
- scratch = deepcopy(data)
247
- rewrite_paths: list[str] = []
248
- constraints: dict[str, tuple[str, ...]] = {}
277
+ def _checked_correction_paths(
278
+ corrections: Sequence[dict[str, Any]],
279
+ parsed: Mapping[str, tuple[str | int, ...]],
280
+ data: dict[str, Any],
281
+ ) -> tuple[list[dict[str, Any]], list[str]]:
282
+ valid: list[dict[str, Any]] = []
283
+ defects: list[str] = []
249
284
  for item in corrections:
250
285
  correction_id, path, kind = str(item.get("id")), str(item.get("path")), str(item.get("kind"))
251
286
  tokens = parsed.get(correction_id)
@@ -255,72 +290,147 @@ def check_corrections(
255
290
  if owned_defect:
256
291
  defects.append(_defect(correction_id, path, owned_defect))
257
292
  continue
258
- found, value = _resolve(data, tokens)
259
- if not found:
260
- parent_found, parent = _parent(data, tokens)
261
- appendable = (
262
- kind == "rewrite"
263
- and isinstance(tokens[-1], int)
264
- and parent_found
265
- and isinstance(parent, list)
266
- and tokens[-1] == len(parent)
267
- )
268
- if not appendable:
269
- defects.append(_defect(
270
- correction_id, path,
271
- "path does not resolve in the base narrative"
272
- + (" (a rewrite may append at index len(array))" if kind == "rewrite" else ""),
273
- ))
274
- continue
275
- elif "current" in item and not _current_matches(item["current"], value):
276
- defects.append(_defect(
277
- correction_id, path,
278
- f"current value is {_short(value)}, not {_short(item['current'])}",
279
- ))
293
+ reason = _correction_path_defect(item, tokens, data)
294
+ if reason:
295
+ defects.append(_defect(correction_id, path, reason))
280
296
  continue
281
- if kind == "rewrite":
282
- rewrite_paths.append(path)
283
- constraints[correction_id] = _constraints_for(path, block_rules, schema)
284
-
285
- replacements = [c for c in corrections if c.get("kind") == "replace" and str(c.get("id")) in parsed]
286
- removals = [c for c in corrections if c.get("kind") == "remove" and str(c.get("id")) in parsed]
287
- defective_ids = {
288
- line.split("correction=", 1)[1].split(" ", 1)[0] for line in defects if "correction=" in line
289
- }
290
- for item in replacements:
291
- correction_id = str(item.get("id"))
292
- if correction_id in defective_ids:
297
+ valid.append(item)
298
+ return valid, defects
299
+
300
+
301
+ def _correction_path_defect(
302
+ item: Mapping[str, Any], tokens: Sequence[str | int], data: dict[str, Any],
303
+ ) -> str | None:
304
+ found, value = _resolve(data, tokens)
305
+ kind = item.get("kind")
306
+ parent_found, parent = _parent(data, tokens)
307
+ appendable = parent_found and (
308
+ (isinstance(parent, dict) and isinstance(tokens[-1], str))
309
+ or (isinstance(parent, list) and tokens[-1] == len(parent))
310
+ )
311
+ if found and kind in {"add", "move"}:
312
+ return "destination already exists; use replace to change an existing value"
313
+ if not found and not (kind in {"add", "move", "rewrite"} and appendable):
314
+ return "path does not resolve in the base narrative"
315
+ if kind == "move":
316
+ try:
317
+ source = parse_field_path(str(item.get("fromPath") or ""))
318
+ except ValueError as exc:
319
+ return f"invalid fromPath: {exc}"
320
+ owned_defect = writer_owned_path_defect(str(item["fromPath"]))
321
+ if owned_defect:
322
+ return f"fromPath {owned_defect}"
323
+ if source == tuple(tokens[:len(source)]):
324
+ return "move destination must not be inside its source"
325
+ if any(isinstance(token, int) for token in (*source, *tokens)):
326
+ return "move supports object fields only; use add/remove for array items"
327
+ found, value = _resolve(data, source)
328
+ if not found:
329
+ return "fromPath does not resolve in the base narrative"
330
+ if found and "current" in item and not _current_matches(item["current"], value):
331
+ return f"current value is {_short(value)}, not {_short(item['current'])}"
332
+ return None
333
+
334
+
335
+ def _apply_mechanical_corrections(
336
+ data: dict[str, Any], corrections: Sequence[dict[str, Any]],
337
+ parsed: Mapping[str, tuple[str | int, ...]],
338
+ ) -> dict[str, Any]:
339
+ scratch = deepcopy(data)
340
+ removals: list[tuple[str | int, ...]] = []
341
+ for item in corrections:
342
+ tokens = parsed[str(item["id"])]
343
+ if item.get("kind") in {"replace", "add"}:
344
+ _set(scratch, tokens, deepcopy(item.get("replacement")))
345
+ elif item.get("kind") == "move":
346
+ source = parse_field_path(str(item["fromPath"]))
347
+ _set(scratch, tokens, deepcopy(_resolve(data, source)[1]))
348
+ removals.append(source)
349
+ elif item.get("kind") == "remove":
350
+ removals.append(tokens)
351
+ for tokens in sorted(set(removals), key=lambda ts: tuple(map(_removal_order, ts)), reverse=True):
352
+ if _resolve(scratch, tokens)[0]:
353
+ _remove(scratch, tokens)
354
+ return scratch
355
+
356
+
357
+ def _remaining_correction_defects(
358
+ errors: Sequence[str], rewrites: Sequence[dict[str, Any]],
359
+ ) -> list[str]:
360
+ defects: list[str] = []
361
+ for error in errors:
362
+ location, separator, _ = error.partition(": ")
363
+ location = location if separator else "-"
364
+ if any(location == item["path"] or location.startswith(f"{item['path']}.")
365
+ or location.startswith(f"{item['path']}[") for item in rewrites):
293
366
  continue
294
- _set(scratch, parsed[correction_id], deepcopy(item.get("replacement")))
295
- for item in sorted(
296
- removals,
297
- key=lambda c: tuple(_removal_order(t) for t in parsed[str(c.get("id"))]),
298
- reverse=True,
367
+ defects.append(_defect("applied", location, error))
368
+ return defects
369
+
370
+
371
+ def _recount_planning_steps(
372
+ scratch: dict[str, Any], corrections: Sequence[dict[str, Any]],
373
+ rewrites: Sequence[dict[str, Any]],
374
+ ) -> tuple[dict[str, Any], ...]:
375
+ planning = scratch.get("implementationPlanning")
376
+ if not isinstance(planning, dict) or any(
377
+ str(item["path"]).startswith("implementationPlanning") for item in rewrites
299
378
  ):
300
- correction_id = str(item.get("id"))
301
- if correction_id in defective_ids:
379
+ return ()
380
+ stages, stage_map = planning.get("stages"), planning.get("stageMap")
381
+ if not isinstance(stages, list) or not isinstance(stage_map, list):
382
+ return ()
383
+ derived: list[dict[str, Any]] = []
384
+ used = {item.get("id") for item in corrections}
385
+ for index, row in enumerate(stage_map):
386
+ if not isinstance(row, dict):
302
387
  continue
303
- _remove(scratch, parsed[correction_id])
304
-
305
- # 원장의 일부가 결함이어도 나머지를 적용한 사본은 검증한다 — 리드가 한
306
- # 회차에 전건을 보게 하려는 것이 이 모듈의 이유다. 결함인 교정은 적용하지
307
- # 않았으므로 그 자리는 기준 서사의 값 그대로다.
308
- if scratch is not None:
309
- errors = validate_writer_owned(scratch, schema)
310
- if semantic_validator is not None:
311
- errors.extend(semantic_validator(scratch))
312
- for error in errors:
313
- location, separator, _ = error.partition(": ")
314
- location = location if separator else "-"
315
- if any(location == rp or location.startswith(f"{rp}.") or location.startswith(f"{rp}[")
316
- for rp in rewrite_paths):
317
- continue
318
- defects.append(_defect("applied", location, error))
319
-
320
- mechanical = not defects and not rewrite_paths
321
- return CorrectionsCheck(
322
- ledger_dict, corrections, scratch, mechanical, constraints, tuple(defects)
323
- )
388
+ matches = [stage for stage in stages if isinstance(stage, dict)
389
+ and stage.get("stage") == row.get("stage")]
390
+ if len(matches) != 1 or not isinstance(matches[0].get("stepwiseExecution"), list):
391
+ continue
392
+ steps = matches[0]["stepwiseExecution"]
393
+ if not all(isinstance(step, dict) for step in steps) or row.get("stepCount") == len(steps):
394
+ continue
395
+ correction_id = next((f"RC-{n:03d}" for n in range(1, 1000) if f"RC-{n:03d}" not in used), None)
396
+ if correction_id is None:
397
+ break
398
+ used.add(correction_id)
399
+ derived.append({
400
+ "id": correction_id, "kind": "replace" if "stepCount" in row else "add",
401
+ "path": f"implementationPlanning.stageMap[{index}].stepCount",
402
+ "replacement": len(steps), "reason": "Derived from the matching stage's stepwiseExecution rows",
403
+ **({"current": row["stepCount"]} if "stepCount" in row else {}),
404
+ })
405
+ row["stepCount"] = len(steps)
406
+ return tuple(derived)
407
+
408
+
409
+ def _complete_rewrites(
410
+ ledger: dict[str, Any], results: Mapping[str, Any], data: dict[str, Any],
411
+ ) -> tuple[dict[str, Any], list[str]]:
412
+ errors = validate(dict(results), corrections_schema()["$defs"]["RewriteResults"])
413
+ if errors:
414
+ return ledger, [_defect("rewrite-results", "-", error) for error in errors]
415
+ if results.get("baseNarrativeSha256") != ledger["baseNarrativeSha256"]:
416
+ errors.append("base narrative hash does not match")
417
+ rows = results.get("replacements")
418
+ rows = rows if isinstance(rows, list) else []
419
+ ids = [row.get("id") for row in rows if isinstance(row, dict)]
420
+ expected = {item["id"] for item in ledger["corrections"] if item.get("kind") == "rewrite"}
421
+ if len(ids) != len(set(ids)) or set(ids) != expected:
422
+ errors.append("replacement ids must name every rewrite exactly once and no other correction")
423
+ if errors:
424
+ return ledger, [_defect("rewrite-results", "-", error) for error in errors]
425
+ completed = deepcopy(ledger)
426
+ replacements = {row["id"]: row["replacement"] for row in rows}
427
+ for item in completed["corrections"]:
428
+ if item.get("kind") != "rewrite":
429
+ continue
430
+ found, _value = _resolve(data, parse_field_path(item["path"]))
431
+ item["kind"] = "replace" if found else "add"
432
+ item["replacement"] = replacements[item["id"]]
433
+ return completed, []
324
434
 
325
435
 
326
436
  def _constraints_for(
@@ -348,11 +458,10 @@ def _constraints_for(
348
458
  def render_applied_narrative(
349
459
  check: CorrectionsCheck, schema: Mapping[str, Any],
350
460
  ) -> str:
351
- """기계적 교정(replace·remove)만 있는 원장을 적용한 서사 본문.
461
+ """확정 값으로 구성된 원장을 적용한 서사 본문.
352
462
 
353
- 작성자 라운드 없이 okstra `reportNarrativePath` 쓰는 문서다. 결함이
354
- 있거나 `rewrite` 가 섞인 대조 결과는 받지 않는다 — 그 원장은 작성자가 다시
355
- 써야 할 자리가 있다.
463
+ 기계적 교정이나 작성자가 제출한 교체 값의 대조를 마친 문서다. 결함이
464
+ 있거나 아직 교체 값이 제출되지 않은 `rewrite` 가 섞인 결과는 받지 않는다.
356
465
  """
357
466
  if not check.mechanical or check.scratch is None:
358
467
  pending = [
@@ -363,8 +472,8 @@ def render_applied_narrative(
363
472
  list(check.defects)
364
473
  or [_defect(
365
474
  ", ".join(pending) or "-", "-",
366
- "rewrite entries need a writer round; only a ledger of replace "
367
- "and remove entries can be applied mechanically",
475
+ "rewrite entries need a writer round; supply rewrite results "
476
+ "or use only replace, remove, add and move entries",
368
477
  )]
369
478
  )
370
479
  return render_narrative(check.scratch, schema)
@@ -398,10 +507,11 @@ def render_corrections_section(
398
507
  lines = [
399
508
  "## Corrections",
400
509
  "",
401
- f"Your previous attempt is preserved at `{base_narrative_rel}`. Read that copy "
402
- "and reproduce it verbatim, then apply only the corrections below. Do not read "
403
- "your own `**Result Path:**` for this purpose: it is the same document and you "
404
- f"are about to overwrite it. Corrections ledger: `{corrections_rel}`.",
510
+ f"Your previous attempt is preserved at `{base_narrative_rel}`. "
511
+ "The runtime preserves all fields outside the corrections. Read only the target "
512
+ "values and cited evidence below; expand to related source sections when needed. "
513
+ f"Corrections ledger: `{corrections_rel}`.",
514
+ f"Base narrative SHA-256: `{check.ledger['baseNarrativeSha256']}`.",
405
515
  "",
406
516
  ]
407
517
  for item in check.corrections:
@@ -413,15 +523,21 @@ def render_corrections_section(
413
523
  except ValueError:
414
524
  label = f"`{path}`"
415
525
  head = f"- {correction_id} `{kind}` — {label} (`{path}`): "
416
- if kind == "replace":
526
+ if kind in {"replace", "add"}:
417
527
  current = (
418
528
  f"current {_scalar_text(item['current'])}, " if "current" in item else ""
419
529
  )
420
530
  action = f"{current}write exactly {_scalar_text(item.get('replacement'))}."
421
531
  elif kind == "remove":
422
532
  action = "remove this item or field entirely; renumber the remaining `- Item N` rows 1..N."
533
+ elif kind == "move":
534
+ action = f"move the unchanged value from `{item.get('fromPath')}`."
423
535
  else:
424
536
  action = f"rewrite this part so that: {item.get('rule')}"
537
+ found, value = _resolve(check.scratch, parse_field_path(path))
538
+ action += " Current value: " + (json.dumps(value, ensure_ascii=False) if found else "<absent>") + "."
539
+ if item.get("evidenceRefs"):
540
+ action += " Evidence: " + ", ".join(f"`{ref}`" for ref in item["evidenceRefs"]) + "."
425
541
  constraint_lines = check.constraints.get(correction_id, ())
426
542
  if constraint_lines:
427
543
  action += " Schema constraint: " + " ".join(constraint_lines)
@@ -597,8 +597,12 @@ def run_finalize(
597
597
 
598
598
  if only:
599
599
  selected = set(only)
600
- if ctx.report_contract_version == "3.0" and STEP_TRANSLATE in selected:
601
- selected.add(STEP_PREFLIGHT)
600
+ if ctx.report_contract_version == "3.0":
601
+ if STEP_TRANSLATE in selected:
602
+ selected.add(STEP_PREFLIGHT)
603
+ if selected - {STEP_TOKEN_USAGE}:
604
+ # 부분 재개도 이전 조립본이 아닌 현재 소유자 입력을 소비한다.
605
+ selected.add(STEP_PROJECT_ACTIVITY)
602
606
  contract_order = (
603
607
  V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
604
608
  )
@@ -644,13 +648,15 @@ def _execute_finalize_steps(
644
648
  steps: list[dict[str, Any]] = []
645
649
  first_failure = ""
646
650
  validated = False
647
- preflight_failed = False
651
+ blocked: dict[str, str] = {}
648
652
  for name, command in commands:
649
653
  # 실패한 시퀀스가 worktree 를 거두면 재작업 대상이 사라지고, 검증 안 된
650
654
  # 레코드를 기억하면 형제 task 가 그것을 결론으로 읽는다.
651
- if name in _SKIPPED_AFTER_FAILURE and first_failure:
655
+ if name in _SKIPPED_AFTER_FAILURE and first_failure and name not in blocked:
652
656
  continue
653
- if name == STEP_TRANSLATE and preflight_failed:
657
+ if name in blocked:
658
+ steps.append({"name": name, "status": "skipped", "exitCode": None,
659
+ "blockedBy": blocked[name]})
654
660
  continue
655
661
  if before_step is not None:
656
662
  before_step(name)
@@ -663,8 +669,16 @@ def _execute_finalize_steps(
663
669
  detail="\n".join(filter(None, [result.stderr, result.stdout])) or f"{name} exited {result.returncode}",
664
670
  )
665
671
  steps.append(step)
666
- if name == STEP_PREFLIGHT:
667
- preflight_failed = result.returncode != 0
672
+ if result.returncode != 0:
673
+ if name == STEP_PROJECT_ACTIVITY:
674
+ for consumer, _ in commands:
675
+ if consumer != STEP_TOKEN_USAGE or ctx.report_contract_version != "3.0":
676
+ blocked[consumer] = name
677
+ elif name == STEP_PREFLIGHT:
678
+ blocked[STEP_TRANSLATE] = name
679
+ blocked[STEP_SPAWN_FOLLOWUPS] = name
680
+ elif name == STEP_RENDER_VIEWS:
681
+ blocked[STEP_VALIDATE_RUN] = name
668
682
  if result.returncode != 0 and not first_failure:
669
683
  first_failure = f"{name} failed with exit code {result.returncode}"
670
684
  if name == STEP_VALIDATE_RUN and result.returncode == 0:
@@ -1106,6 +1120,9 @@ def _step_summary_lines(
1106
1120
  lines = []
1107
1121
  for step in result.get("steps") or []:
1108
1122
  code = step.get("exitCode")
1123
+ if step.get("status") == "skipped":
1124
+ lines.append(f" [skip] {step.get('name')} (blocked by {step.get('blockedBy')})")
1125
+ continue
1109
1126
  mark = "ok " if code == 0 else "FAIL"
1110
1127
  lines.append(f" [{mark}] {step.get('name')} (exit {code})")
1111
1128
  for name in order:
@@ -1129,7 +1146,7 @@ def _recovery_step_names(
1129
1146
  failed = {
1130
1147
  string_value(step.get("name"))
1131
1148
  for step in (result.get("steps") or [])
1132
- if step.get("exitCode") != 0
1149
+ if step.get("exitCode") not in (None, 0)
1133
1150
  }
1134
1151
  for index, name in enumerate(order):
1135
1152
  if name in failed:
@@ -5,6 +5,7 @@ from ..report_contract import html_template_for
5
5
  from .models import HtmlRoute
6
6
  from .view_models.change_impact_analysis import build_change_impact_analysis_view
7
7
  from .view_models.error_analysis import build_error_analysis_view
8
+ from .view_models.technical_verification import build_technical_verification_view
8
9
  from .view_models.feature_analysis import build_feature_analysis_view
9
10
  from .view_models.final_verification import build_final_verification_view
10
11
  from .view_models.improvement_discovery import build_improvement_discovery_view
@@ -23,6 +24,7 @@ class HtmlRenderError(ValueError):
23
24
 
24
25
 
25
26
  VIEW_BUILDERS = {
27
+ "technical-verification": build_technical_verification_view,
26
28
  "requirements-discovery": build_requirements_discovery_view,
27
29
  "improvement-discovery": build_improvement_discovery_view,
28
30
  "error-analysis": build_error_analysis_view,
@@ -0,0 +1,21 @@
1
+ """기술 검증 입력과 관측 결과의 보고서 표시."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..common import evidence_index
6
+ from ..models import HumanReportView
7
+
8
+
9
+ def build_technical_verification_view(data: dict) -> HumanReportView:
10
+ anchored = ("technicalVerification.checks",)
11
+ return HumanReportView(
12
+ "technical-verification",
13
+ "html/tasks/technical-verification.template.html",
14
+ {
15
+ "humanSummary": data["humanSummary"],
16
+ "verification": data["technicalVerification"],
17
+ "evidenceIndex": evidence_index(data, anchored),
18
+ },
19
+ (),
20
+ anchored_fields=anchored,
21
+ )
@@ -1,6 +1,8 @@
1
1
  """역할별 실행 입력을 최종 리포트 조각으로 바꾸는 순수 투영."""
2
2
  from __future__ import annotations
3
3
 
4
+ from okstra_token_usage.blocks import accounting_workers
5
+
4
6
  import json
5
7
  from copy import deepcopy
6
8
  from typing import Any, Mapping, Sequence
@@ -148,8 +150,7 @@ def project_execution(
148
150
  "role": "Okstra lead",
149
151
  }
150
152
  lead_usage = team_state.get("leadUsage")
151
- workers = team_state.get("workers")
152
- worker_rows = workers if isinstance(workers, list) else []
153
+ worker_rows = accounting_workers(team_state)
153
154
  result: dict[str, Any] = {
154
155
  "executionStatus": [
155
156
  _execution_row(
@@ -181,7 +182,7 @@ def _usage_row(summary: Mapping[str, Any], prefix: str, cost: object) -> dict[st
181
182
 
182
183
  def _worker_details(team_state: Mapping[str, Any]) -> list[dict[str, Any]]:
183
184
  result = []
184
- for worker in team_state.get("workers") or []:
185
+ for worker in accounting_workers(team_state):
185
186
  if not isinstance(worker, Mapping):
186
187
  continue
187
188
  usage = worker.get("usage") if isinstance(worker.get("usage"), Mapping) else {}