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.
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +7 -6
- package/runtime/python/okstra_ctl/analysis_packet.py +7 -3
- package/runtime/python/okstra_ctl/incremental_scope.py +11 -2
- package/runtime/python/okstra_ctl/manager_launch.py +2 -1
- package/runtime/python/okstra_ctl/manager_paths.py +4 -0
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +5 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +5 -4
- package/runtime/python/okstra_ctl/phases/implementation/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/report.py +66 -19
- package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation.template.html +8 -7
- package/runtime/python/okstra_ctl/phases/implementation/wizard.py +1 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +8 -3
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -2
- package/runtime/python/okstra_ctl/phases/implementation_planning/report_assets/implementation-planning-input.template.md +1 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +6 -0
- package/runtime/python/okstra_ctl/plan_items.py +24 -3
- package/runtime/python/okstra_ctl/report_html/run_usage.py +9 -0
- package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -8
- package/runtime/python/okstra_ctl/report_projections.py +18 -2
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +19 -0
- package/runtime/python/okstra_ctl/report_translation.py +27 -6
- package/runtime/python/okstra_ctl/stage_close.py +13 -2
- package/runtime/python/okstra_ctl/stage_map.py +25 -0
- package/runtime/python/okstra_ctl/stage_map_cli.py +3 -2
- package/runtime/python/okstra_ctl/stage_targets.py +17 -3
- package/runtime/python/okstra_ctl/wizard/sources.py +2 -1
- package/runtime/python/okstra_ctl/wizard/state.py +30 -1
- package/runtime/python/okstra_ctl/wizard/steps_options.py +19 -0
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +4 -2
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +7 -1
- package/runtime/schemas/final-report-v3.0.schema.json +18 -0
- package/runtime/templates/reports/html/assets/base.css +4 -2
- package/runtime/templates/reports/html/base.template.html +14 -9
- package/runtime/templates/reports/html/i18n/en.json +91 -2
- package/runtime/templates/reports/html/i18n/ko.json +91 -2
- package/runtime/templates/reports/html/macros/forms.html +8 -7
- 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 =
|
|
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
|
|
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,
|
|
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(
|
|
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}",
|
|
379
|
-
elif isinstance(node, str) and
|
|
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,
|
|
187
|
-
commit in the project repo, and the stage's
|
|
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
|
|
34
|
-
schema-v2 report adds that
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
50
|
-
{% for row in businessFlow %}
|
|
51
|
-
<p>{{ row.mode }}
|
|
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>
|