okstra 0.209.6 → 0.211.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 (40) hide show
  1. package/docs/cli.md +2 -2
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/prompts/wizard/prompts.ko.json +7 -6
  5. package/runtime/python/okstra_ctl/analysis_packet.py +7 -3
  6. package/runtime/python/okstra_ctl/incremental_scope.py +11 -2
  7. package/runtime/python/okstra_ctl/manager_launch.py +2 -1
  8. package/runtime/python/okstra_ctl/manager_paths.py +4 -0
  9. package/runtime/python/okstra_ctl/phases/final_verification/target.py +5 -0
  10. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +5 -4
  11. package/runtime/python/okstra_ctl/phases/implementation/profile.md +1 -1
  12. package/runtime/python/okstra_ctl/phases/implementation/report.py +66 -19
  13. package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation.template.html +8 -7
  14. package/runtime/python/okstra_ctl/phases/implementation/wizard.py +1 -0
  15. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +8 -3
  16. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -2
  17. package/runtime/python/okstra_ctl/phases/implementation_planning/report_assets/implementation-planning-input.template.md +1 -1
  18. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +6 -0
  19. package/runtime/python/okstra_ctl/plan_items.py +24 -3
  20. package/runtime/python/okstra_ctl/report_html/run_usage.py +9 -0
  21. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -8
  22. package/runtime/python/okstra_ctl/report_projections.py +18 -2
  23. package/runtime/python/okstra_ctl/report_synthesis_packet.py +19 -0
  24. package/runtime/python/okstra_ctl/report_translation.py +27 -6
  25. package/runtime/python/okstra_ctl/stage_close.py +13 -2
  26. package/runtime/python/okstra_ctl/stage_map.py +25 -0
  27. package/runtime/python/okstra_ctl/stage_map_cli.py +3 -2
  28. package/runtime/python/okstra_ctl/stage_targets.py +17 -3
  29. package/runtime/python/okstra_ctl/wizard/sources.py +2 -1
  30. package/runtime/python/okstra_ctl/wizard/state.py +30 -1
  31. package/runtime/python/okstra_ctl/wizard/steps_options.py +19 -0
  32. package/runtime/python/okstra_ctl/wizard/steps_plan.py +4 -2
  33. package/runtime/python/okstra_ctl/wizard/steps_roles.py +7 -1
  34. package/runtime/schemas/final-report-v3.0.schema.json +18 -0
  35. package/runtime/templates/reports/html/assets/base.css +4 -2
  36. package/runtime/templates/reports/html/base.template.html +14 -9
  37. package/runtime/templates/reports/html/i18n/en.json +91 -2
  38. package/runtime/templates/reports/html/i18n/ko.json +91 -2
  39. package/runtime/templates/reports/html/macros/forms.html +8 -7
  40. package/runtime/validators/validate-implementation-plan-stages.py +46 -5
@@ -342,14 +342,6 @@ def stage_map_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]
342
342
  )
343
343
 
344
344
 
345
- def change_map_figure(*, nodes: Sequence[VisualNode], title: str) -> FigureModel:
346
- return replace(
347
- dependency_figure(nodes=nodes, edges=(), title=title),
348
- kind="change-map",
349
- figure_id="change-map",
350
- )
351
-
352
-
353
345
  def matrix_figure(*, items: Iterable[VisualNode], title: str, x_label: str, y_label: str) -> FigureModel:
354
346
  nodes = tuple(items)
355
347
  summary = f"{len(nodes)} items compared by {x_label} and {y_label}"
@@ -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(
@@ -132,6 +132,16 @@ def close_stage(
132
132
  f"stage {stage} is not in this task's Stage Map (has {known or 'none'})",
133
133
  stage="stage-map",
134
134
  )
135
+ if any(
136
+ isinstance(row, dict) and row.get("stage_number") == stage
137
+ and row.get("cancelled")
138
+ for row in stage_map.stages
139
+ ):
140
+ raise StateError(
141
+ f"stage {stage} is cancelled in this task's latest plan — a cancelled "
142
+ "stage is never done; close the stage that replaces it instead",
143
+ stage="stage-map",
144
+ )
135
145
 
136
146
  recorded = last_lifecycle_status_by_stage(read_consumers(plan_run_root))
137
147
  if recorded.get(stage) in _SETTLED:
@@ -183,8 +193,9 @@ _CLI_EPILOG = r"""Usage:
183
193
  Records the `done` row an implementation run would have written, for a stage
184
194
  whose work is already committed but which never registered as done (the run
185
195
  ended before writing its carry sidecar). Refuses unless the Stage Map has that
186
- stage, no `done`/`failed` row exists for it, `--from-commit` resolves to a
187
- commit in the project repo, and the stage's conformance gate permits progress.
196
+ stage and does not mark it cancelled, no `done`/`failed` row exists for it,
197
+ `--from-commit` resolves to a commit in the project repo, and the stage's
198
+ conformance gate permits progress.
188
199
 
189
200
  Output: JSON { ok, taskKey, taskRoot, stage, headCommit, conformance,
190
201
  consumersPath }. Exit 1 on a refusal (the reason names what to do), 2 when
@@ -41,6 +41,7 @@ class StageMapStage:
41
41
  depends_on: tuple[int, ...]
42
42
  step_count: int
43
43
  exit_contract_summary: str
44
+ cancelled: bool = False
44
45
 
45
46
 
46
47
  @dataclass(frozen=True)
@@ -356,9 +357,27 @@ def _parse_data_stage_map_row(
356
357
  parse_stage_dependencies(depends_on, row_number, source_plan_path),
357
358
  step_count,
358
359
  exit_summary,
360
+ stage_row_is_cancelled(value, row_number, source_plan_path),
359
361
  )
360
362
 
361
363
 
364
+ STAGE_STATUSES = ("active", "cancelled")
365
+
366
+
367
+ def stage_row_is_cancelled(
368
+ value: dict[str, Any], row_number: int, source_plan_path: str = "",
369
+ ) -> bool:
370
+ """구조화 Stage Map 행의 `status`. 없으면 active 다."""
371
+ status = value.get("status", "active")
372
+ if status not in STAGE_STATUSES:
373
+ raise StageMapError(
374
+ "stage_map",
375
+ f"structured Stage Map row {row_number} has invalid status {status!r}",
376
+ source_plan_path,
377
+ )
378
+ return status == "cancelled"
379
+
380
+
362
381
  def _parse_schema_v2_stage_map(
363
382
  data: dict[str, Any], source_plan_path: str,
364
383
  ) -> list[StageMapStage]:
@@ -493,11 +512,17 @@ def stage_map_records(stages: Iterable[StageMapStage]) -> list[dict[str, Any]]:
493
512
  "depends_on": list(stage.depends_on),
494
513
  "step_count": stage.step_count,
495
514
  "exit_contract_summary": stage.exit_contract_summary,
515
+ "cancelled": stage.cancelled,
496
516
  }
497
517
  for stage in stages
498
518
  ]
499
519
 
500
520
 
521
+ def active_stage_records(records: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
522
+ """취소되지 않은 stage 기록 — 통합·whole-task 게이트·handoff 가 순회하는 집합."""
523
+ return [record for record in records if not record.get("cancelled")]
524
+
525
+
501
526
  def _latest_plan_report(task_root: Path) -> Path | None:
502
527
  reports_dir = RunRef.from_task_root(
503
528
  task_root, "implementation-planning"
@@ -30,8 +30,9 @@ _CLI_EPILOG = r"""Usage:
30
30
 
31
31
  Output: default and --json emit JSON { ok, taskKey, taskRoot, state, sourcePlanPath,
32
32
  stages:[…], doneStages:[int], planning:{…} }; --text emits fixed count/name/value fields.
33
- Each stage row carries stage_number, title, depends_on, step_count and exit_contract_summary; a
34
- schema-v2 report adds that stage's sliceValue, acceptance and exitContract.
33
+ Each stage row carries stage_number, title, depends_on, step_count, exit_contract_summary and
34
+ cancelled (true for a row the plan marks `status: cancelled`); a schema-v2 report adds that
35
+ stage's sliceValue, acceptance and exitContract.
35
36
  planning carries the report's task-level rows — rollbackStrategy, validationChecklist,
36
37
  crossProjectDependencies, dependencyMigrationRisk, recommendedOption — so a consumer renders the
37
38
  plan instead of re-summarising the report body. Both are absent for a schema-v1 report.
@@ -53,10 +53,12 @@ class StageLifecycle:
53
53
  step_count: int
54
54
  deps_satisfied: bool
55
55
  blocked_by: list[int]
56
+ cancelled: bool = False
56
57
 
57
58
  @property
58
59
  def status(self) -> str:
59
- """Lifecycle state, in precedence order: done > active > ready > blocked.
60
+ """Lifecycle state, in precedence order:
61
+ done > cancelled > active > ready > blocked.
60
62
 
61
63
  ``started`` and ``reserved`` collapse into ``active`` because every
62
64
  caller that distinguishes them already words the two the same way (see
@@ -64,6 +66,8 @@ class StageLifecycle:
64
66
  """
65
67
  if self.done:
66
68
  return "done"
69
+ if self.cancelled:
70
+ return "cancelled"
67
71
  if self.started or self.reserved:
68
72
  return "active"
69
73
  return "ready" if self.deps_satisfied else "blocked"
@@ -171,6 +175,11 @@ class StageLifecycleSnapshot:
171
175
  raise StageTargetError(
172
176
  f"--stage {n} already completed (consumers.jsonl status:done exists)"
173
177
  )
178
+ if lifecycle.status == "cancelled":
179
+ raise StageTargetError(
180
+ f"--stage {n} is cancelled in the approved plan's Stage Map; "
181
+ "it is never executed — run the stage that replaces it"
182
+ )
174
183
  if lifecycle.status == "active":
175
184
  # Wording is load-bearing: okstra-run's unattended chain matches it
176
185
  # to terminate normally instead of raising an exception gate.
@@ -183,8 +192,8 @@ class StageLifecycleSnapshot:
183
192
  blocked = [lc for lc in self.lifecycles if lc.status == "blocked"]
184
193
  if not blocked:
185
194
  return (
186
- "no stage is ready: every stage is done or occupied by "
187
- "another run"
195
+ "no stage is ready: every stage is done, cancelled, or "
196
+ "occupied by another run"
188
197
  )
189
198
  detail = "; ".join(
190
199
  f"stage {lc.stage} blocked by "
@@ -203,6 +212,7 @@ class StageLifecycleSnapshot:
203
212
  return [
204
213
  lifecycle.handoff_eligibility_record()
205
214
  for lifecycle in self.lifecycles
215
+ if lifecycle.status != "cancelled"
206
216
  ]
207
217
 
208
218
 
@@ -242,6 +252,7 @@ def stage_lifecycle_snapshot_from_state(
242
252
  pr_covered=stage_number in consumer_state.pr_covered_stages,
243
253
  head_commit=str(done_row.get("head_commit") or ""),
244
254
  report_path=str(done_row.get("report_path") or ""),
255
+ cancelled=bool(stage.get("cancelled")),
245
256
  ))
246
257
  return StageLifecycleSnapshot(
247
258
  stage_map=stage_map,
@@ -710,8 +721,11 @@ def _resolve_and_integrate_whole_task_unlocked(
710
721
  """Integrate and resolve a whole-task target while the caller owns the lock."""
711
722
  from .consumers import latest_done_by_stage
712
723
  from .stage_integrate import IntegrateError, integrate_stages
724
+ from .stage_map import active_stage_records
713
725
  from .worktree import _git, is_dirty_excluding_okstra
714
726
 
727
+ stage_map = active_stage_records(stage_map)
728
+
715
729
  # 끝나지 않은 stage 가 있으면 _resolve_whole_task_target 이 어차피 거부한다.
716
730
  # 그 전에 끝난 stage 를 머지해 두면 거부된 요청이 task worktree 에 머지를
717
731
  # 남긴다(되돌리지 않는다). 머지하기 전에 같은 조건으로 거부한다.
@@ -231,7 +231,8 @@ def _stage_lifecycle_snapshot(
231
231
  return read_stage_lifecycle_snapshot(
232
232
  [{"stage_number": s.stage_number,
233
233
  "depends_on": list(s.depends_on),
234
- "step_count": s.step_count} for s in stages],
234
+ "step_count": s.step_count,
235
+ "cancelled": s.cancelled} for s in stages],
235
236
  Path(state.approved_plan_path).resolve().parents[1],
236
237
  recover_from_carry=False,
237
238
  reserved_stages=reserved_stages,
@@ -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
 
@@ -249,7 +249,9 @@ def _whole_task_allowed(
249
249
  if done is None:
250
250
  done = _stage_lifecycle_snapshot(state, stages).done_stages
251
251
  return whole_task_verification_allowed(
252
- stage_numbers=tuple(stage.stage_number for stage in stages),
252
+ stage_numbers=tuple(
253
+ stage.stage_number for stage in stages if not stage.cancelled
254
+ ),
253
255
  done_stages=done,
254
256
  )
255
257
 
@@ -468,7 +470,7 @@ def _submit_impl_stage_pick(state: WizardState, answer: str) -> Optional[str]:
468
470
  state, stages, reserved_stages=_reserved_stage_numbers(state))
469
471
  done = snapshot.done_stages
470
472
  occupied = {lc.stage for lc in snapshot.lifecycles
471
- if lc.status in ("done", "active")}
473
+ if lc.status in ("done", "active", "cancelled")}
472
474
  chosen = _impl_chosen_stages(t, picks, answer, all_nums, occupied)
473
475
  ordered = order_stage_closure(
474
476
  [(s.stage_number, s.depends_on) for s in stages], chosen, done)
@@ -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"],
@@ -8158,6 +8158,13 @@
8158
8158
  "exitContractSummary": {
8159
8159
  "type": "string",
8160
8160
  "minLength": 1
8161
+ },
8162
+ "status": {
8163
+ "enum": [
8164
+ "active",
8165
+ "cancelled"
8166
+ ],
8167
+ "description": "Absent means active. A cancelled stage keeps its row and number so the Stage Map still runs 1..N; execution, gates and handoff skip it. Only a stage that has not started may be cancelled."
8161
8168
  }
8162
8169
  }
8163
8170
  },
@@ -9942,6 +9949,10 @@
9942
9949
  "planStep": {
9943
9950
  "type": "string",
9944
9951
  "minLength": 1
9952
+ },
9953
+ "summary": {
9954
+ "type": "string",
9955
+ "minLength": 1
9945
9956
  }
9946
9957
  }
9947
9958
  },
@@ -10002,6 +10013,13 @@
10002
10013
  },
10003
10014
  "tddEvidence": {
10004
10015
  "type": "string"
10016
+ },
10017
+ "checkId": {
10018
+ "type": "string",
10019
+ "pattern": "^VC-\\d{3,}$"
10020
+ },
10021
+ "advisory": {
10022
+ "type": "boolean"
10005
10023
  }
10006
10024
  }
10007
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>