okstra 0.210.0 → 0.212.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 (30) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/docs/cli.md +3 -2
  4. package/docs/project-structure-overview.md +2 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/wizard/prompts.ko.json +3 -3
  8. package/runtime/python/okstra_ctl/locks.py +39 -0
  9. package/runtime/python/okstra_ctl/manager_launch.py +2 -1
  10. package/runtime/python/okstra_ctl/manager_paths.py +4 -0
  11. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +5 -4
  12. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +9 -1
  13. package/runtime/python/okstra_ctl/phases/implementation/report.py +66 -19
  14. package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation.template.html +8 -7
  15. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +6 -2
  16. package/runtime/python/okstra_ctl/report_html/run_usage.py +9 -0
  17. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -8
  18. package/runtime/python/okstra_ctl/report_projections.py +18 -2
  19. package/runtime/python/okstra_ctl/report_synthesis_packet.py +19 -0
  20. package/runtime/python/okstra_ctl/report_translation.py +27 -6
  21. package/runtime/python/okstra_ctl/wizard/state.py +30 -1
  22. package/runtime/python/okstra_ctl/wizard/steps_options.py +19 -0
  23. package/runtime/python/okstra_ctl/wizard/steps_roles.py +7 -1
  24. package/runtime/python/okstra_ctl/worktree_lock_cli.py +87 -0
  25. package/runtime/schemas/final-report-v3.0.schema.json +11 -0
  26. package/runtime/templates/reports/html/assets/base.css +4 -2
  27. package/runtime/templates/reports/html/base.template.html +14 -9
  28. package/runtime/templates/reports/html/i18n/en.json +91 -2
  29. package/runtime/templates/reports/html/i18n/ko.json +91 -2
  30. package/runtime/templates/reports/html/macros/forms.html +8 -7
@@ -33,6 +33,22 @@ _STATUS = {
33
33
  }
34
34
 
35
35
 
36
+ def _report_workers(team_state: Mapping[str, Any]) -> list[dict]:
37
+ """리드 자신의 워커 항목을 뺀 워커 행.
38
+
39
+ team-state 는 리드를 `workerId: lead` 워커로도 적는다(agent 없음, 상태
40
+ `dispatched`). 리드 행은 `lead`·`leadUsage` 에서 따로 만들므로 이 항목을
41
+ 표에 올리면 "Codex · lead · unknown · 미실행" 행이 하나 더 생긴다.
42
+ """
43
+ return [
44
+ row for row in accounting_workers(team_state)
45
+ if "lead" not in {
46
+ str(row.get("workerId") or "").strip().lower(),
47
+ str(row.get("role") or "").strip().lower(),
48
+ }
49
+ ]
50
+
51
+
36
52
  def _text(value: object, fallback: str) -> str:
37
53
  text = str(value or "").strip()
38
54
  return text or fallback
@@ -148,7 +164,7 @@ def project_execution(
148
164
  "role": "Okstra lead",
149
165
  }
150
166
  lead_usage = team_state.get("leadUsage")
151
- worker_rows = accounting_workers(team_state)
167
+ worker_rows = _report_workers(team_state)
152
168
  result: dict[str, Any] = {
153
169
  "executionStatus": [
154
170
  _execution_row(
@@ -180,7 +196,7 @@ def _usage_row(summary: Mapping[str, Any], prefix: str, cost: object) -> dict[st
180
196
 
181
197
  def _worker_details(team_state: Mapping[str, Any]) -> list[dict[str, Any]]:
182
198
  result = []
183
- for worker in accounting_workers(team_state):
199
+ for worker in _report_workers(team_state):
184
200
  if not isinstance(worker, Mapping):
185
201
  continue
186
202
  usage = worker.get("usage") if isinstance(worker.get("usage"), Mapping) else {}
@@ -295,6 +295,7 @@ class ReportSynthesisPacket:
295
295
  "Do not invent a value when a source is missing or contradictory.",
296
296
  "Write only the narrative, pointer, and audit artifacts named by the dispatch.",
297
297
  *_INTERNAL_IDENTIFIER_INSTRUCTION,
298
+ *_READER_PROSE_INSTRUCTION,
298
299
  *self._schema_instructions(),
299
300
  *self._task_instructions(),
300
301
  *self._carry_instructions(),
@@ -335,6 +336,7 @@ class ReportSynthesisPacket:
335
336
  # 작성자가 실제로 읽는 것은 마크다운이다. JSON 정본에만 실으면 지시가
336
337
  # 도달하지 않는다.
337
338
  lines.extend(f"- {text}" for text in _INTERNAL_IDENTIFIER_INSTRUCTION)
339
+ lines.extend(f"- {text}" for text in _READER_PROSE_INSTRUCTION)
338
340
  lines.extend(f"- {text}" for text in self._schema_instructions())
339
341
  lines.extend(f"- {text}" for text in self._task_instructions())
340
342
  lines.extend(f"- {text}" for text in self._carry_instructions())
@@ -573,6 +575,23 @@ _INTERNAL_IDENTIFIER_INSTRUCTION = (
573
575
  )
574
576
 
575
577
 
578
+ # 리포트를 읽는 사람은 run 을 보지 않았다. 아래 규칙도 집행 코드가 없고(서사는
579
+ # 자유 서술이다) 저자 지시로만 전달한다 — dev-11126 #003 서사가 어긴 네 가지.
580
+ _READER_PROSE_INSTRUCTION = (
581
+ "Write every reader-facing field for a reader who did not watch the run: "
582
+ "each sentence must make sense without the worker results, the plan, or "
583
+ "the lead's notes.",
584
+ "Explain a plan- or run-internal id (`VC-`, `OOP-`, `P-Step-`, `OF-`, "
585
+ "`IO-`, `C-`) in words the first time a field uses it, or leave it out; "
586
+ "never let a bare id carry the meaning of a sentence.",
587
+ "Keep commit hashes and file paths out of narrative sentences; put them "
588
+ "only in evidence lines (`evidence`, `evidenceRefs` and other citation "
589
+ "fields).",
590
+ "Name a term by what it does (\"the function that decides whether the "
591
+ "server is local\") rather than by jargon (\"predicate\").",
592
+ )
593
+
594
+
576
595
  CARRIED_STAGES_LABEL = "Carried plan stages"
577
596
 
578
597
 
@@ -14,7 +14,9 @@ reader merely reads is offered, and the translator decides.
14
14
 
15
15
  When a key name carries load-bearing values in even one place, it counts as
16
16
  structural everywhere. Missing a translation leaves English on the page;
17
- translating an anchor id breaks the link.
17
+ translating an anchor id breaks the link. ``PROSE_FIELDS`` is the one
18
+ exception: a field named by its enclosing keys, where that key holds prose
19
+ even though the same name is an id or a path elsewhere.
18
20
  """
19
21
  from __future__ import annotations
20
22
 
@@ -328,6 +330,23 @@ STRUCTURAL_KEYS = frozenset({
328
330
  })
329
331
 
330
332
 
333
+ # Fields whose key is structural by name but holds a sentence at this position.
334
+ # Each tuple matches the tail of the key path with array indices dropped;
335
+ # `endStateCoverage[].coveredBy` is absent because there it is a linked id.
336
+ PROSE_FIELDS = frozenset({
337
+ ("requirementCoverage", "coveredBy"),
338
+ ("commitList", "files"),
339
+ ("manualUserTest", "items", "target"),
340
+ ("manualUserTest", "items", "steps"),
341
+ })
342
+
343
+
344
+ def _is_prose_field(keys: tuple[str, ...]) -> bool:
345
+ if keys and keys[-1] in PROSE_KEYS:
346
+ return True
347
+ return any(keys[-len(field):] == field for field in PROSE_FIELDS)
348
+
349
+
331
350
  # A key name says what a field is *for*; these say what one value actually is.
332
351
  # `evidence` is the largest prose bucket in a real report and also holds bare
333
352
  # `src/server.ts:33` citations — the key cannot separate them, the value can.
@@ -367,16 +386,18 @@ def unescape_token(token: str) -> str:
367
386
  return token.replace("~1", "/").replace("~0", "~")
368
387
 
369
388
 
370
- def _walk(node: Any, pointer: str, key: str) -> Iterator[tuple[str, str]]:
389
+ def _walk(node: Any, pointer: str, keys: tuple[str, ...]) -> Iterator[tuple[str, str]]:
371
390
  if isinstance(node, Mapping):
372
391
  for child_key, value in node.items():
373
- yield from _walk(value, f"{pointer}/{escape_token(str(child_key))}", str(child_key))
392
+ yield from _walk(
393
+ value, f"{pointer}/{escape_token(str(child_key))}", (*keys, str(child_key))
394
+ )
374
395
  elif isinstance(node, (list, tuple)):
375
396
  for index, value in enumerate(node):
376
397
  # An array element inherits the key that named the array, so
377
398
  # `evidence: [...]` translates each entry.
378
- yield from _walk(value, f"{pointer}/{index}", key)
379
- elif isinstance(node, str) and key in PROSE_KEYS and is_translatable_value(node):
399
+ yield from _walk(value, f"{pointer}/{index}", keys)
400
+ elif isinstance(node, str) and _is_prose_field(keys) and is_translatable_value(node):
380
401
  yield pointer, node
381
402
 
382
403
 
@@ -386,7 +407,7 @@ def extract(data: Mapping[str, Any]) -> dict[str, str]:
386
407
  The translator fills in this map rather than authoring pointers, so a
387
408
  sidecar cannot cite a path the document does not have.
388
409
  """
389
- return dict(_walk(data, "", ""))
410
+ return dict(_walk(data, "", ()))
390
411
 
391
412
 
392
413
  def group_translation_pointers(
@@ -377,6 +377,25 @@ def _initial_cross_verification_requirement(
377
377
  ), None)
378
378
 
379
379
 
380
+ def _assigned_models_by_role(payload: dict[str, Any]) -> dict[str, list[tuple[str, str]]]:
381
+ """run-inputs 의 `workerAssignments` 를 역할별 (provider, model) 로.
382
+
383
+ 지금 run-inputs 의 `workers` 는 provider 가 아니라 worker id(`claude-planner`)를
384
+ 적는다. 그 값으로 `<provider>Model` 을 찾으면 아무 모델도 복원되지 않는다.
385
+ """
386
+ rows = payload.get("workerAssignments")
387
+ assigned: dict[str, list[tuple[str, str]]] = {}
388
+ for row in rows if isinstance(rows, list) else ():
389
+ if not isinstance(row, dict):
390
+ continue
391
+ role, provider, model = row.get("role"), row.get("provider"), row.get("model")
392
+ if isinstance(role, str) and isinstance(provider, str) and provider:
393
+ assigned.setdefault(role, []).append(
394
+ (provider, model if isinstance(model, str) else "")
395
+ )
396
+ return assigned
397
+
398
+
380
399
  def _role_selections_from_run_inputs(
381
400
  state: WizardState,
382
401
  payload: dict[str, Any],
@@ -385,7 +404,10 @@ def _role_selections_from_run_inputs(
385
404
  profile = _load_role_profile_for_state(state)
386
405
  pool = ModelPool.from_registry(default_provider_registry())
387
406
  cross_requirement = _initial_cross_verification_requirement(profile)
407
+ assigned = _assigned_models_by_role(payload)
388
408
  providers = _legacy_worker_providers(payload)
409
+ if cross_requirement is not None and cross_requirement.role in assigned:
410
+ providers = [provider for provider, _ in assigned[cross_requirement.role]]
389
411
  if cross_requirement is not None:
390
412
  selected_count = cross_requirement.recommended_count
391
413
  if providers and cross_requirement.min_count < cross_requirement.max_count:
@@ -400,8 +422,13 @@ def _role_selections_from_run_inputs(
400
422
  model.provider_id
401
423
  for model in pool.default_candidates(cross_requirement.role)
402
424
  ]
425
+ assigned_models = [model for _, model in assigned.get(cross_requirement.role, ())]
403
426
  for ordinal, provider in enumerate(providers[:selected_count], start=1):
404
- raw_model = payload.get(f"{provider}Model", "")
427
+ raw_model = (
428
+ assigned_models[ordinal - 1]
429
+ if ordinal <= len(assigned_models)
430
+ else payload.get(f"{provider}Model", "")
431
+ )
405
432
  model_ref = _legacy_model_ref(
406
433
  pool,
407
434
  provider,
@@ -433,6 +460,8 @@ def _role_selections_from_run_inputs(
433
460
  if not requirement.dynamic and requirement.min_count > 0
434
461
  }
435
462
  for role, provider, raw_model in provider_rows:
463
+ if assigned.get(role):
464
+ provider, raw_model = assigned[role][0]
436
465
  if role not in static_roles or not isinstance(provider, str) or not provider:
437
466
  continue
438
467
  model_ref = _legacy_model_ref(pool, provider, role, raw_model)
@@ -2,10 +2,12 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from dataclasses import dataclass
5
+ from datetime import datetime
5
6
  from pathlib import Path
6
7
  from typing import Callable, Optional
7
8
 
8
9
  from okstra_ctl.clarification_items import user_response_sidecars
10
+ from okstra_ctl.manager_paths import MANAGER_CONTEXT_DIRECTIVE_PREFIX
9
11
  from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
10
12
  from okstra_ctl.report_language import is_language_tag
11
13
  from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
@@ -169,12 +171,29 @@ def _submit_optional_cached_pick(
169
171
  )
170
172
 
171
173
 
174
+ def _directive_context_note(state: WizardState, directive: str) -> str:
175
+ """manager directive 는 파일을 가리킬 뿐이라, 그 파일이 언제 쓰였는지 보여 준다.
176
+
177
+ 직전 run 이후 정한 결정은 그 파일에 없다. 날짜가 없으면 사용자는 재사용할
178
+ directive 가 새 범위를 담는지 판단할 수 없었다(fontsninja dev-11127 re-plan).
179
+ """
180
+ if not directive.startswith(MANAGER_CONTEXT_DIRECTIVE_PREFIX):
181
+ return ""
182
+ path = Path(directive[len(MANAGER_CONTEXT_DIRECTIVE_PREFIX):].strip()).expanduser()
183
+ labels = _p(state.workspace_root, "directive_pick")["labels"]
184
+ if not path.is_file():
185
+ return labels["context_missing"].format(path=path)
186
+ written = datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
187
+ return labels["context_file"].format(path=path, date=written)
188
+
189
+
172
190
  _DIRECTIVE_PICK_SPEC = _OptionalCachedPickSpec(
173
191
  step=S_DIRECTIVE_PICK, prompt_key="directive_pick",
174
192
  recommend_token=_REUSE_LAST_TOKEN, label_key="reuse_last", echo_suffix_key="reuse",
175
193
  suggest=_suggest_last_directive, snippet_style="prefix",
176
194
  cache_attr="last_directive_cached", target_attr="directive",
177
195
  pending_attr="directive_pending_text",
196
+ recommend_note=_directive_context_note,
178
197
  )
179
198
 
180
199
 
@@ -63,7 +63,13 @@ def _assignment_model(inputs: dict, worker_id: str, provider: str) -> str:
63
63
 
64
64
 
65
65
  def _build_reuse_previous(state: WizardState) -> Prompt:
66
- t = _p(state.workspace_root, "reuse_previous")
66
+ directive = _latest_run_inputs(
67
+ state, phase_segment=slugify_task_segment(state.task_type)
68
+ ).get("directive")
69
+ t = _p(
70
+ state.workspace_root, "reuse_previous",
71
+ directive=directive if isinstance(directive, str) and directive else "(없음)",
72
+ )
67
73
  return Prompt(
68
74
  step=S_REUSE_PREVIOUS, kind="pick",
69
75
  label=t["label"],
@@ -0,0 +1,87 @@
1
+ """`okstra worktree-lock` — 선언된 검증 명령을 worktree 단위 배타 락 안에서 실행한다."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import contextlib
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from okstra_project.dirs import okstra_home
12
+
13
+ from .locks import worktree_exec_lock
14
+
15
+ _CLI_EPILOG = r"""Every verifier of a stage runs in the same worktree at the same time, and each
16
+ re-runs the plan's Tier 1 commands itself. A command that starts a server on a
17
+ fixed port (a dev server on 3000, a fixture API on 8899) then collides with the
18
+ same command from another verifier. This launcher runs the command unchanged,
19
+ one invocation at a time per worktree:
20
+
21
+ okstra worktree-lock --worktree <target.cwd> --command '<target.command>'
22
+
23
+ The command runs through $SHELL -c (or /bin/sh) with the worktree as its
24
+ working directory. Its stdout and stderr pass through untouched and its exit
25
+ code is this command's exit code; lock notices go to stderr only.
26
+
27
+ The lock file is <okstra home>/.locks/worktree-exec/<hash of the worktree
28
+ realpath>.lock, outside the worktree and the project's .okstra tree. When it
29
+ cannot be created or locked, the command still runs, unlocked, after a warning
30
+ on stderr.
31
+ """
32
+
33
+
34
+ def _shell() -> str:
35
+ return os.environ.get("SHELL", "") or "/bin/sh"
36
+
37
+
38
+ def _announce_wait(lockfile: Path) -> None:
39
+ sys.stderr.write(
40
+ f"okstra worktree-lock: another command holds {lockfile}; waiting\n"
41
+ )
42
+ sys.stderr.flush()
43
+
44
+
45
+ def _run(command: str, worktree: Path) -> int:
46
+ code = subprocess.run([_shell(), "-c", command], cwd=worktree, check=False).returncode
47
+ # 시그널로 죽은 자식은 음수를 준다. 셸이 보고하는 128+N 으로 맞춘다.
48
+ return 128 - code if code < 0 else code
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ parser = argparse.ArgumentParser(
53
+ prog="okstra worktree-lock",
54
+ description="Run one declared shell command in a worktree under that worktree's exclusive lock.",
55
+ epilog=_CLI_EPILOG,
56
+ formatter_class=argparse.RawDescriptionHelpFormatter,
57
+ )
58
+ parser.add_argument(
59
+ "--worktree", type=Path, required=True,
60
+ help="worktree the command runs in; also the lock key (resolved to its realpath)",
61
+ )
62
+ parser.add_argument(
63
+ "--command", required=True,
64
+ help="the declared command, run verbatim through the shell",
65
+ )
66
+ args = parser.parse_args(argv)
67
+ worktree = args.worktree.resolve()
68
+ if not worktree.is_dir():
69
+ sys.stderr.write(f"okstra worktree-lock: not a directory: {worktree}\n")
70
+ return 2
71
+ with contextlib.ExitStack() as stack:
72
+ try:
73
+ stack.enter_context(
74
+ worktree_exec_lock(okstra_home(), worktree, on_wait=_announce_wait)
75
+ )
76
+ except OSError as exc:
77
+ # 잠그지 못해도 검증자의 재실행 자체를 막지 않는다. 동시 실행의 위험은
78
+ # 락 도입 전과 같고, 그 사실을 stderr 에 남긴다.
79
+ sys.stderr.write(
80
+ f"okstra worktree-lock: warning: cannot take the lock ({exc}); running unlocked\n"
81
+ )
82
+ sys.stderr.flush()
83
+ return _run(args.command, worktree)
84
+
85
+
86
+ if __name__ == "__main__":
87
+ raise SystemExit(main())
@@ -9949,6 +9949,10 @@
9949
9949
  "planStep": {
9950
9950
  "type": "string",
9951
9951
  "minLength": 1
9952
+ },
9953
+ "summary": {
9954
+ "type": "string",
9955
+ "minLength": 1
9952
9956
  }
9953
9957
  }
9954
9958
  },
@@ -10009,6 +10013,13 @@
10009
10013
  },
10010
10014
  "tddEvidence": {
10011
10015
  "type": "string"
10016
+ },
10017
+ "checkId": {
10018
+ "type": "string",
10019
+ "pattern": "^VC-\\d{3,}$"
10020
+ },
10021
+ "advisory": {
10022
+ "type": "boolean"
10012
10023
  }
10013
10024
  }
10014
10025
  },
@@ -12,7 +12,8 @@ body { margin: 0; background: Canvas; color: CanvasText; }
12
12
  .report-meta dd { margin: 0; }
13
13
  .lede { max-width: 70ch; font-size: clamp(1.05rem, 2vw, 1.35rem); line-height: 1.4; margin: 0 0 1rem; }
14
14
  .eyebrow { text-transform: uppercase; letter-spacing: .08em; font-size: .78rem; color: GrayText; }
15
- main { display: grid; gap: 1rem; padding-bottom: 3rem; }
15
+ /* minmax(0, 1fr): auto 열은 가장 넓은 자식만큼 커져서 긴 토큰 하나가 모든 section 을 넓혔다. */
16
+ main { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; padding-bottom: 3rem; }
16
17
  section { padding: 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
17
18
  h2 { margin-top: 0; font-size: 1.45rem; }
18
19
  .report-section-toggle > summary { cursor: pointer; }
@@ -188,4 +189,5 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
188
189
  /* A seven-column table needs 42em of floor, more than a phone can give. */
189
190
  @media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } nav.report-index ol { columns: 1; } }
190
191
  @media print { .skip-link, .back-to-top-wrap, script { display: none !important; } body { color: #000; background: #fff; } section { break-inside: avoid; border-color: #bbb; } .visualization-fallback { display: table; } }
191
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: .1em .3em; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
192
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: .1em .3em; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); overflow-wrap: anywhere; }
193
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -27,14 +27,12 @@
27
27
  <thead><tr>
28
28
  <th scope="col">{{ t('base.role') }}</th>
29
29
  <th scope="col">{{ t('base.execution-label') }}</th>
30
- <th scope="col">{{ t('base.status') }}</th>
31
30
  </tr></thead>
32
31
  <tbody>
33
32
  {% for row in executionRoles %}
34
33
  <tr>
35
- <td>{{ row.role }}</td>
34
+ <td>{{ row.role | enum_label('executionRole') }}</td>
36
35
  <td><code>{{ row.executionLabel }}</code></td>
37
- <td>{{ row.status }}</td>
38
36
  </tr>
39
37
  {% endfor %}
40
38
  </tbody>
@@ -45,14 +43,20 @@
45
43
  <main id="main-content" data-report-role="human-main">
46
44
  {% block human_content %}{% endblock %}
47
45
  {% if businessFlow | default([]) %}
46
+ {% set flow_failed = businessFlow | selectattr('status', 'equalto', 'failed') | list %}
48
47
  <section data-report-section="business-flow">
49
- <h2>Business Flow Explanations</h2>
50
- {% for row in businessFlow %}
51
- <p>{{ row.mode }}: {{ row.status }}. {{ row.summary }}</p>
52
- <ul>{% for kind, path in row.reports.items() %}<li><a href="{{ path }}">{{ kind }}</a></li>{% endfor %}</ul>
53
- {% if row.error %}<p>Explanation error: {{ row.error }}</p>{% endif %}
54
- {% if row.id %}<p>Retry: <code>okstra explain-flow rerun --execution {{ row.id }}</code></p>{% endif %}
48
+ <h2>{{ t('base.business-flow') }}</h2>
49
+ {% for row in businessFlow if row.status != 'failed' %}
50
+ <p>{{ row.mode | enum_label('businessFlowMode') }} · {{ row.status | enum_label('businessFlowStatus') }}. {{ row.summary }}</p>
51
+ {% if row.reports %}<ul>{% for kind, path in row.reports.items() %}<li><a href="{{ path }}">{{ kind }}</a></li>{% endfor %}</ul>{% endif %}
55
52
  {% endfor %}
53
+ {% if flow_failed %}
54
+ {# 실패 행의 오류·재실행 명령은 운영자용이라 접어 둔다. #}
55
+ <p>{{ t('base.business-flow-failed-count') | replace('{count}', flow_failed | length) }}</p>
56
+ <details><summary>{{ t('base.business-flow-failure-details') }}</summary>
57
+ <ul>{% for row in flow_failed %}<li>{{ row.mode | enum_label('businessFlowMode') }}{% if row.error %} · {{ t('base.business-flow-error') }} {{ row.error }}{% endif %}{% if row.id %} · {{ t('base.business-flow-rerun') }} <code>okstra explain-flow rerun --execution {{ row.id }}</code>{% endif %}</li>{% endfor %}</ul>
58
+ </details>
59
+ {% endif %}
56
60
  </section>
57
61
  {% endif %}
58
62
  {% if endStates %}
@@ -146,6 +150,7 @@
146
150
  <tr><th scope="row">{{ t('runUsage.row-workers') }}</th><td class="figure">{{ runUsage.worker.rawTokens }}</td><td class="figure">{{ runUsage.worker.cacheReadTokens }}</td><td class="figure">{{ runUsage.worker.billableTokens }}</td><td class="figure">{{ runUsage.worker.cost }}</td><td class="figure"></td></tr>
147
151
  <tr class="grand-total"><th scope="row">{{ t('runUsage.row-total') }}</th><td class="figure">{{ runUsage.grand.rawTokens }}</td><td class="figure">{{ runUsage.grand.cacheReadTokens }}</td><td class="figure">{{ runUsage.grand.billableTokens }}</td><td class="figure">{{ runUsage.grand.cost }}</td><td class="figure"></td></tr>
148
152
  {% if runUsage.cliCost %}<tr><th scope="row">{{ t('runUsage.row-cli') }}</th><td class="figure"></td><td class="figure"></td><td class="figure"></td><td class="figure">{{ runUsage.cliCost }}</td><td class="figure"></td></tr>{% endif %}
153
+ {% if runUsage.grandWithCli %}<tr class="grand-total"><th scope="row">{{ t('runUsage.row-total-with-cli') }}</th><td class="figure"></td><td class="figure"></td><td class="figure"></td><td class="figure">{{ runUsage.grandWithCli }}</td><td class="figure"></td></tr>{% endif %}
149
154
  {% if runUsage.taskCumulative %}<tr class="task-cumulative"><th scope="row">{{ t('runUsage.row-task-cumulative') | replace('{runs}', runUsage.taskCumulative.runCount) }}</th><td class="figure">{{ runUsage.taskCumulative.rawTokens }}</td><td class="figure">{{ runUsage.taskCumulative.cacheReadTokens }}</td><td class="figure">{{ runUsage.taskCumulative.billableTokens }}</td><td class="figure">{{ runUsage.taskCumulative.cost }}</td><td class="figure"></td></tr>{% endif %}
150
155
  </tfoot>
151
156
  </table>
@@ -147,6 +147,74 @@
147
147
  "deferred": "Deferred",
148
148
  "not-applicable": "Not applicable",
149
149
  "blocked": "Blocked"
150
+ },
151
+ "clarificationKind": {
152
+ "material": "Missing information",
153
+ "decision": "Your decision",
154
+ "data-point": "A fact to confirm"
155
+ },
156
+ "clarificationStatus": {
157
+ "open": "Open",
158
+ "answered": "Answered",
159
+ "resolved": "Resolved",
160
+ "obsolete": "No longer relevant"
161
+ },
162
+ "approvalDisposition": {
163
+ "select": "Choose this option",
164
+ "accept-risk": "Accept the risk",
165
+ "request-revision": "Request a revision",
166
+ "reject": "Reject"
167
+ },
168
+ "approvalClassification": {
169
+ "user-decision": "Needs your decision",
170
+ "noncritical-dissent": "Non-critical disagreement",
171
+ "correctness-critical": "Affects correctness"
172
+ },
173
+ "clarificationScope": {
174
+ "in-repo": "This repository only",
175
+ "cross-repo": "Other repositories too",
176
+ "new-schema": "New schema",
177
+ "deferrable": "Can be deferred"
178
+ },
179
+ "executionRole": {
180
+ "leader": "Lead",
181
+ "analyser": "Analyser",
182
+ "critic": "Critic",
183
+ "designer": "Designer",
184
+ "implementer": "Implementer",
185
+ "planner": "Planner",
186
+ "report-writer": "Report writer",
187
+ "translator": "Translator",
188
+ "verifier": "Verifier"
189
+ },
190
+ "requirementStatus": {
191
+ "covered": "Covered",
192
+ "gap": "Not covered",
193
+ "documented-deviation": "Approved deviation",
194
+ "blocked": "Blocked"
195
+ },
196
+ "rollbackCategory": {
197
+ "Pure code": "Code only",
198
+ "Feature-flag-gated": "Behind a feature flag",
199
+ "Schema/config/stateful": "Schema, config or stored state"
200
+ },
201
+ "rollbackResult": {
202
+ "ok": "Passed",
203
+ "not-applicable": "Not applicable",
204
+ "advisory — human-run": "Advisory: a person runs it"
205
+ },
206
+ "businessFlowMode": {
207
+ "pre": "Before the change",
208
+ "contribute": "This phase's contribution",
209
+ "post": "After the change",
210
+ "unknown": "Unknown phase"
211
+ },
212
+ "businessFlowStatus": {
213
+ "prepared": "Prepared",
214
+ "running": "Running",
215
+ "failed": "Failed",
216
+ "partial": "Partly explained",
217
+ "complete": "Explained"
150
218
  }
151
219
  },
152
220
  "enumHint": {
@@ -194,7 +262,12 @@
194
262
  "agent-activity": "Agent activity",
195
263
  "agent-activity-intro": "What each agent was asked to do in this run and how it ended. Decision cards and prose cite these rows by id.",
196
264
  "agent": "Agent",
197
- "activity": "Activity"
265
+ "activity": "Activity",
266
+ "business-flow": "Business flow explanations",
267
+ "business-flow-failed-count": "{count} business flow explanations failed",
268
+ "business-flow-failure-details": "Failure details and rerun commands",
269
+ "business-flow-error": "Error:",
270
+ "business-flow-rerun": "Rerun:"
198
271
  },
199
272
  "runUsage": {
200
273
  "heading": "What this run cost",
@@ -214,7 +287,8 @@
214
287
  "row-total": "Total",
215
288
  "row-cli": "CLI calls (billed separately)",
216
289
  "cache-read-tokens": "Cache re-reads",
217
- "row-task-cumulative": "This task so far ({runs} runs)"
290
+ "row-task-cumulative": "This task so far ({runs} runs)",
291
+ "row-total-with-cli": "Total including CLI calls"
218
292
  },
219
293
  "macros": {
220
294
  "forms": {
@@ -585,6 +659,21 @@
585
659
  "result": "Result",
586
660
  "no-rollback-check-was-recorded": "No rollback check was recorded.",
587
661
  "stage-carry-over": "Stage carry-over",
662
+ "carry-sidecar": "Sidecar",
663
+ "carry-commit-range": "Commit range",
664
+ "carry-steps": "Steps",
665
+ "carry-files-changed": "Files changed",
666
+ "carry-raw": "Sidecar JSON as written",
667
+ "file": "File",
668
+ "change": "Change",
669
+ "lines": "Lines",
670
+ "plan-step": "Plan step",
671
+ "action-created": "Created",
672
+ "action-modified": "Modified",
673
+ "action-deleted": "Deleted",
674
+ "what-it-does": "What it does",
675
+ "advisory": "advisory",
676
+ "entry-count": "{count} entries",
588
677
  "manual-user-test": "Manual user test",
589
678
  "environment": "Environment:",
590
679
  "expected": "Expected:",