okstra 0.109.0 → 0.111.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/README.kr.md +2 -1
- package/README.md +2 -1
- package/docs/for-ai/skills/okstra-run.md +7 -5
- package/docs/kr/architecture.md +11 -12
- package/docs/kr/cli.md +4 -4
- package/docs/project-structure-overview.md +12 -12
- package/docs/task-process/README.md +4 -4
- package/docs/task-process/common-flow.md +8 -5
- package/docs/task-process/implementation.md +4 -5
- package/docs/task-process/release-handoff.md +3 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +1 -0
- package/runtime/agents/workers/codex-worker.md +1 -0
- package/runtime/prompts/lead/convergence.md +7 -7
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/_implementation-diff-review.md +43 -0
- package/runtime/prompts/profiles/_implementation-executor.md +7 -9
- package/runtime/prompts/profiles/_implementation-self-check.md +11 -5
- package/runtime/python/okstra_ctl/codex_dispatch.py +3 -0
- package/runtime/python/okstra_ctl/consumers.py +4 -0
- package/runtime/python/okstra_ctl/handoff.py +5 -23
- package/runtime/python/okstra_ctl/implementation_stage.py +11 -11
- package/runtime/python/okstra_ctl/report_views.py +76 -26
- package/runtime/python/okstra_ctl/stage_targets.py +152 -0
- package/runtime/python/okstra_ctl/wizard.py +47 -0
- package/runtime/python/okstra_ctl/worktree.py +14 -13
- package/runtime/python/okstra_project/__init__.py +2 -0
- package/runtime/python/okstra_project/state.py +44 -2
- package/runtime/skills/okstra-run/SKILL.md +21 -17
- package/src/commands/execute/wizard.mjs +3 -1
- package/src/commands/inspect/task-show.mjs +11 -31
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Post-write counterpart to _coding-conventions-preflight.md. The preflight gate
|
|
3
|
+
runs BEFORE the first Edit/Write and loads the conventions; THIS gate runs AFTER
|
|
4
|
+
the edits are written and BEFORE the executor's final commit, sweeping the actual
|
|
5
|
+
diff against those same conventions with an exhaustive file×rule matrix. It is a
|
|
6
|
+
prevention pass owned by the executor — it fixes findings in place, so the same
|
|
7
|
+
defects do not reach the verifier's Static design gate or post-merge PR review.
|
|
8
|
+
|
|
9
|
+
Same delivery paths as the other executor gates (see _implementation-executor.md
|
|
10
|
+
"Pre-implementation context exploration"):
|
|
11
|
+
- Claude executor reads this file directly after its last Edit / Write.
|
|
12
|
+
- codex / antigravity executor cannot read this path (outside the CLI sandbox),
|
|
13
|
+
so the lead appends this file's body into the persisted executor prompt at
|
|
14
|
+
dispatch time (see codex_dispatch.py `_implementation_executor_tail`).
|
|
15
|
+
The `Pre-commit diff review sweep` heading below is the literal string the CLI
|
|
16
|
+
wrapper's "Executor post-write gate forwarding check" greps for in the persisted
|
|
17
|
+
prompt (agents/workers/_cli-wrapper-template.md → Prompt Composition).
|
|
18
|
+
-->
|
|
19
|
+
|
|
20
|
+
# Pre-commit diff review sweep (BLOCKING — before the executor's final commit)
|
|
21
|
+
|
|
22
|
+
Lint/test green is necessary but NOT sufficient — self-mocked tests, interaction-only assertions, untruthful names, and unreadable functions all pass a green pipeline. This sweep is what keeps them out of the diff. Run it once, after the last `Edit` / `Write` of the stage, before the final commit. Fix every finding in place (you are the executor — you may edit); this is a prevention pass, not a report you hand off.
|
|
23
|
+
|
|
24
|
+
Do not scan holistically and stop when it "looks fine". Work the matrix exhaustively — the failure mode this gate exists to prevent is a real defect surviving because you eyeballed the diff instead of enumerating it.
|
|
25
|
+
|
|
26
|
+
## Method — enumerate the diff, then walk every (file × applicable-rule) cell
|
|
27
|
+
|
|
28
|
+
1. **List every changed file:** `git diff --name-only <stage-base>..HEAD`. That list is your worklist — cover it to the end; no sampling.
|
|
29
|
+
2. **Classify each file and select its rule set** from the conventions the preflight already loaded (do NOT re-derive the rules here — read them from the routed pack):
|
|
30
|
+
- any source file → `clean-code.md`: truthful + standalone names, plain-English summary test, single-purpose ≤50-line functions, DRY (incl. scattered domain literals + documented forks), no magic numbers, shallow nesting, comments explain why.
|
|
31
|
+
- test file (`*.spec.*` / `*.test.*` / `test_*.py` / `*_test.go` …) → `clean-code.md` "Testing discipline": no self-mocking of the SUT, behavioral (outcome) assertions not interaction-only, no tautological delegation assertion.
|
|
32
|
+
- port / adapter / domain file, when the hexagonal overlay is loaded → `architectures/hexagonal.md`: no business logic in a port body, adapter methods are I/O only (no post-fetch filtering on domain state, no `findValid*`/`findActive*` names hiding a rule), domain objects declared under the domain boundary.
|
|
33
|
+
A file can hold several roles — apply every rule set that fits it.
|
|
34
|
+
3. **Decide clean-or-finding for each cell.** Read the full file when a rule needs context (never judge a port/adapter/domain or a naming rule from the hunk alone).
|
|
35
|
+
4. **Fix each finding in place** before the commit. When a readability finding is real, the fix is a named helper or named intermediate value — sketch the cleaner shape (a few lines) in your audit note, then apply it. When the fix is genuinely out of this stage's scope, record it as an `Out-of-plan` note instead of silently leaving it.
|
|
36
|
+
|
|
37
|
+
## Coverage footer (BLOCKING deliverable)
|
|
38
|
+
|
|
39
|
+
End your audit-sidecar entry for this sweep with a one-line `Coverage:` footer naming each file you examined and the rule set(s) you applied to it — e.g. `Coverage: users.service.ts [names, plain-english], users.repository.adapter.ts [hexagonal, names], users.service.spec.ts [testing-discipline]`. The footer is how the completion self-check and any reviewer confirm nothing in the diff was skipped. A sweep with no footer is an unfinished sweep.
|
|
40
|
+
|
|
41
|
+
## Graceful degradation
|
|
42
|
+
|
|
43
|
+
When the routed coding-preflight pack is unreadable (codex / antigravity runtime, or the files are absent), do NOT skip the sweep — apply the language-agnostic principles the preflight already listed (no self-mocking, behavioral assertions, truthful/standalone names, single-purpose ≤50-line functions) plus the project's `CLAUDE.md` / `CONTRIBUTING` / lint config, and record `diff-review: resource-unavailable → applied <agnostic principles + project rules>` with the Coverage footer. Never claim a resource read that did not happen.
|
|
@@ -22,9 +22,12 @@ until Phase 5 ends, then drop from active context for Phase 6/7.
|
|
|
22
22
|
- **Coding-conventions preflight (BLOCKING — runs before the first `Edit` / `Write`, and binds the TDD loop below).** The gate body is a single source at `prompts/profiles/_coding-conventions-preflight.md` (sibling of this sidecar). Do NOT re-type that content from memory — deliver it by file so it cannot drift or be dropped:
|
|
23
23
|
- **Claude executor:** Read `_coding-conventions-preflight.md` end-to-end before the first `Edit` / `Write`, then state in ONE line which conventions apply (e.g. `Applying TS + hexagonal overlay; domain at src/domains/*/domain/`).
|
|
24
24
|
- **CLI executor (BLOCKING when the executor provider is `codex` or `antigravity`):** the executor CLI process does NOT share the lead's context, and it cannot Read this sidecar's directory — that path sits outside the CLI sandbox and the CLI only sees its stdin prompt, so a file reference never reaches it. The lead MUST physically append the **body** of `_coding-conventions-preflight.md` into the persisted executor prompt at dispatch time: Read the file from the same absolute directory you read this sidecar from, then `Write`/`cat` its body into the persisted prompt. Never hand-retype it. Enforcement: the CLI wrapper agents refuse an implementation-Executor dispatch whose persisted prompt lacks the literal heading `Coding-conventions preflight`, returning `<SENTINEL_PREFIX>_PREFLIGHT_MISSING` (see `agents/workers/_cli-wrapper-template.md` → Prompt Composition).
|
|
25
|
-
- **
|
|
26
|
-
- **Claude executor:** Read `_implementation-
|
|
27
|
-
- **CLI executor (codex / antigravity):** the CLI process cannot Read this path (outside its sandbox). The lead appends this file's body into the persisted executor prompt at dispatch time,
|
|
25
|
+
- **Pre-commit diff review sweep (BLOCKING — runs AFTER the last `Edit` / `Write`, BEFORE the final commit).** The gate body is a single source at `prompts/profiles/_implementation-diff-review.md` (sibling of this sidecar): an exhaustive file×rule sweep of the actual diff against the conventions the preflight loaded, with fix-in-place and a `Coverage:` footer. Do NOT re-type it from memory — deliver it by file so it cannot drift.
|
|
26
|
+
- **Claude executor:** Read `_implementation-diff-review.md` end-to-end after the stage's last edit, run the sweep over `git diff <stage-base>..HEAD`, fix findings in place, and write its `Coverage:` footer to your audit sidecar before committing.
|
|
27
|
+
- **CLI executor (codex / antigravity):** the CLI process cannot Read this path (outside its sandbox). The lead appends this file's body into the persisted executor prompt at dispatch time, between the preflight body and the self-check body (see `codex_dispatch.py` `_implementation_executor_tail`). The head-less executor honours all three gates from the single prompt.
|
|
28
|
+
- **Completion self-check (BLOCKING — runs BEFORE you claim the stage done).** The gate body is a single source at `prompts/profiles/_implementation-self-check.md` (sibling of this sidecar): the completion gate (diff-review Coverage footer present, functions ≤50 lines, conventions applied, truthful names & why-comments, real build/test run, cleanup). Do NOT re-type it from memory — deliver it by file so it cannot drift.
|
|
29
|
+
- **Claude executor:** Read `_implementation-self-check.md` end-to-end before appending the `status:"done"` row, then write the confirming evidence per item to your audit sidecar.
|
|
30
|
+
- **CLI executor (codex / antigravity):** the CLI process cannot Read this path (outside its sandbox). The lead appends this file's body into the persisted executor prompt at dispatch time, immediately after the diff-review body (see `codex_dispatch.py` `_implementation_executor_tail`). The head-less executor honours all three gates from the single prompt.
|
|
28
31
|
- **Stage discipline transcription (when a preceding stage is `done`):** the lead MUST transcribe the `Stage discipline` rule (from this run's rendered profile — the INCLUDEd `_stage-discipline.md` body) verbatim into the dispatched CLI executor prompt, so a codex/antigravity executor honors the prior-stage behavior-freeze. Declaration-level — no wrapper sentinel.
|
|
29
32
|
- **Non-interactive auto-execution (BLOCKING when the executor provider is `codex` or `antigravity`).** A CLI executor runs head-less (`codex exec` / antigravity equivalent) — there is no human at the keyboard. Skills loaded during the run (tdd, coding-preflight, and others) contain "get user approval", "state your plan to the user and wait", or "ask before proceeding" gates written for interactive sessions; in this run those gates are **already satisfied** by the upstream `implementation-planning` approval (the plan this stage executes was human-approved). The executor MUST NOT stop to request approval, MUST NOT end its turn after only producing a plan, and MUST carry the stage through end-to-end — RED → GREEN → refactor → per-cycle commit → `### Stage Carry Evidence`. The ONLY skill step to skip is the interactive user-approval prompt itself; every other skill rule (TDD discipline, conventions, real-IO isolation) still binds. The lead MUST transcribe this bullet verbatim into the dispatched CLI executor prompt (same reason as the preflight transcription rule above — the CLI process does not share lead context). Stopping early for approval in a head-less run is the observed empty-exit failure (exit 0, no diff): treat it as `contract-violated`.
|
|
30
33
|
- **Mandatory TDD loop**: BEFORE the first `Edit` or `Write` call, the executor MUST apply a red-green-refactor loop for every code change in this run. This is required; skipping it is a `contract-violated` outcome. This governs HOW each step is executed (failing test first → minimal implementation → refactor); it does not override the approved plan's WHAT/file scope.
|
|
@@ -43,12 +46,7 @@ until Phase 5 ends, then drop from active context for Phase 6/7.
|
|
|
43
46
|
- **drift rule** (this section): if a file *named in the plan* has materially drifted, refuse to edit and route back to planning. This protects trust in the approved scope.
|
|
44
47
|
- **out-of-plan rule** (Allowed actions section below): if a step *requires touching a file NOT in the plan list*, that is permitted with `Out-of-plan edits` justification. This handles honest scope discovery during execution.
|
|
45
48
|
- confirm the test/build commands referenced in the plan still exist and run from a clean state
|
|
46
|
-
- **Pre-commit review
|
|
47
|
-
- no new byte-identical or semantically equivalent helper stack appears in two touched services; extract a shared helper/domain module unless the approved plan explicitly justified duplication,
|
|
48
|
-
- no test asserts equality to a direct re-invocation of the collaborator/helper being delegated to; keep literal-value or observable-state assertions,
|
|
49
|
-
- no public method/repository function introduced by this run is left with zero in-scope callers unless it is part of a declared interface contract,
|
|
50
|
-
- no exported/public name hides side effects or omits the entity it acts on,
|
|
51
|
-
- no newly introduced function requires a reader to mentally name several phases that should be helper calls.
|
|
49
|
+
- **Pre-commit diff review sweep (BLOCKING before the executor's final commit):** run the sweep whose body is delivered via `_implementation-diff-review.md` (see the gate-delivery bullet above) over the run diff (`git diff <stage-base>..HEAD`), and fix findings in place before handing to verifiers when the issue is inside this stage's scope. That sidecar is the single source for the sweep's file×rule matrix (DRY / self-mock / behavioral-test / hexagonal / truthful-name / plain-English rules) and its `Coverage:` footer — do not maintain a second copy of the rule list here.
|
|
52
50
|
|
|
53
51
|
## Stage execution contract (this run owns one stage)
|
|
54
52
|
|
|
@@ -9,12 +9,18 @@ appends this file's body into the persisted executor prompt at dispatch time
|
|
|
9
9
|
|
|
10
10
|
# Implementation self-check (BLOCKING — before you claim done)
|
|
11
11
|
|
|
12
|
-
Before declaring the change complete,
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
Before declaring the change complete, confirm each item below against the ACTUAL
|
|
13
|
+
diff — not from memory. This is a re-derivation, not a rubber stamp: enumerate the
|
|
14
|
+
changed files (`git diff --name-only <stage-base>..HEAD`) and, for the per-file
|
|
15
|
+
items, write the confirming evidence per file, not one global "✓". If any item
|
|
16
|
+
fails, fix it or surface the violation — do not claim done on a failing item.
|
|
15
17
|
|
|
16
|
-
-
|
|
18
|
+
- diff-review done: the `Pre-commit diff review sweep` ran over the full diff and its `Coverage:` footer is in your audit sidecar (every changed file named with the rule set applied). No footer → the sweep is unfinished; do not claim done.
|
|
19
|
+
- functions: every new/edited function ≤50 effective lines, single purpose — cite the longest function's actual line count, not "all under 50"
|
|
17
20
|
- conventions: applied the routed pack + project patterns (name which ones)
|
|
18
21
|
- names & comments: names say what, comments say why; no obvious-restatement comments; no truthful-name violations
|
|
19
|
-
- verification: ran the actual build/test
|
|
22
|
+
- verification: ran the actual build/test — paste the exact command line and its real exit code / output tail, never a paraphrased "tests pass"
|
|
20
23
|
- cleanup: no dead or commented-out code left behind
|
|
24
|
+
|
|
25
|
+
Close with a `Self-check coverage:` line naming the files you verified the
|
|
26
|
+
per-file items against, so the Coverage footer above and this gate reconcile.
|
|
@@ -818,6 +818,9 @@ def _implementation_executor_tail(
|
|
|
818
818
|
parts.append(preflight_path.read_text(encoding="utf-8"))
|
|
819
819
|
else:
|
|
820
820
|
parts.append("# Coding-conventions preflight\n\nApply project-local conventions before editing.")
|
|
821
|
+
diff_review_path = profiles / "_implementation-diff-review.md"
|
|
822
|
+
if diff_review_path.is_file():
|
|
823
|
+
parts.append(diff_review_path.read_text(encoding="utf-8"))
|
|
821
824
|
selfcheck_path = profiles / "_implementation-self-check.md"
|
|
822
825
|
if selfcheck_path.is_file():
|
|
823
826
|
parts.append(selfcheck_path.read_text(encoding="utf-8"))
|
|
@@ -68,6 +68,10 @@ def read_stage_consumer_state(
|
|
|
68
68
|
if recover_from_carry:
|
|
69
69
|
backfill_done_from_carry(plan_run_root)
|
|
70
70
|
rows = read_consumers(plan_run_root)
|
|
71
|
+
return stage_consumer_state_from_rows(rows)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def stage_consumer_state_from_rows(rows: List[Dict[str, Any]]) -> StageConsumerState:
|
|
71
75
|
done_rows = [r for r in rows if r.get("status") == "done"]
|
|
72
76
|
done_by_stage = latest_done_by_stage(rows)
|
|
73
77
|
started = {
|
|
@@ -12,7 +12,7 @@ import subprocess
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
14
14
|
|
|
15
|
-
from . import consumers, worktree_registry
|
|
15
|
+
from . import consumers, stage_targets, worktree_registry
|
|
16
16
|
from .final_report_paths import final_report_markdown_path
|
|
17
17
|
from .worktree import (compute_branch_name, compute_worktree_path,
|
|
18
18
|
main_worktree_path, is_dirty_excluding_okstra,
|
|
@@ -44,28 +44,10 @@ def compute_eligibility(stage_map: List[Dict[str, Any]],
|
|
|
44
44
|
rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
45
45
|
"""stage 별 PR 가능 여부와 차단 사유. 의존 폐포는 선택 집합의 속성이라
|
|
46
46
|
여기서는 판정하지 않는다 (assemble 의 check_dependency_closure 가 담당)."""
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
for s in stage_map:
|
|
52
|
-
n = s["stage_number"]
|
|
53
|
-
reasons = []
|
|
54
|
-
if n in in_pr:
|
|
55
|
-
reasons.append("already-in-pr")
|
|
56
|
-
else:
|
|
57
|
-
if n not in done:
|
|
58
|
-
reasons.append("not-done")
|
|
59
|
-
if n not in verified:
|
|
60
|
-
reasons.append("not-verified-accepted")
|
|
61
|
-
out.append({
|
|
62
|
-
"stage": n,
|
|
63
|
-
"depends_on": list(s["depends_on"]),
|
|
64
|
-
"eligible": not reasons,
|
|
65
|
-
"reasons": reasons,
|
|
66
|
-
"head_commit": (done.get(n) or {}).get("head_commit", ""),
|
|
67
|
-
})
|
|
68
|
-
return out
|
|
47
|
+
return stage_targets.stage_lifecycle_snapshot_from_rows(
|
|
48
|
+
stage_map,
|
|
49
|
+
rows,
|
|
50
|
+
).handoff_eligibility()
|
|
69
51
|
|
|
70
52
|
|
|
71
53
|
def latest_whole_task_fv_accepted(project_root, project_id: str,
|
|
@@ -66,32 +66,32 @@ def select_and_provision_implementation_stage(
|
|
|
66
66
|
"""
|
|
67
67
|
del task_key # The registry uses the split identity fields.
|
|
68
68
|
|
|
69
|
-
from .consumers import backfill_done_from_carry
|
|
69
|
+
from .consumers import backfill_done_from_carry
|
|
70
70
|
from . import worktree as _worktree
|
|
71
71
|
from . import worktree_registry as _reg
|
|
72
72
|
|
|
73
73
|
plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
|
|
74
74
|
backfill_done_from_carry(plan_run_root)
|
|
75
75
|
auto_reconcile_best_effort(inp, plan_run_root)
|
|
76
|
-
consumers = read_stage_consumer_state(plan_run_root)
|
|
77
76
|
reserved_stages = _reg.list_active_stage_numbers(
|
|
78
77
|
inp.project_id,
|
|
79
78
|
inp.task_group,
|
|
80
79
|
inp.task_id,
|
|
81
80
|
)
|
|
81
|
+
snapshot = stage_targets.read_stage_lifecycle_snapshot(
|
|
82
|
+
ctx_stage_map,
|
|
83
|
+
plan_run_root,
|
|
84
|
+
reserved_stages=reserved_stages,
|
|
85
|
+
)
|
|
82
86
|
|
|
83
87
|
try:
|
|
84
|
-
selected =
|
|
85
|
-
ctx_stage_map,
|
|
86
|
-
consumers.done_stages,
|
|
87
|
-
inp.stage,
|
|
88
|
-
started_stages=consumers.started_stages,
|
|
89
|
-
reserved_stages=reserved_stages,
|
|
90
|
-
)
|
|
88
|
+
selected = snapshot.resolve_implementation_stage(inp.stage)
|
|
91
89
|
except stage_targets.StageTargetError as exc:
|
|
92
90
|
raise _as_implementation_stage_error(exc) from exc
|
|
93
91
|
|
|
94
|
-
concurrent_stages =
|
|
92
|
+
concurrent_stages = snapshot.concurrent_stage_numbers(
|
|
93
|
+
selected_stage=selected,
|
|
94
|
+
)
|
|
95
95
|
|
|
96
96
|
if executor_worktree_status.startswith("skipped"):
|
|
97
97
|
head = _git_out(inp.project_root, "rev-parse", "HEAD")
|
|
@@ -133,7 +133,7 @@ def select_and_provision_implementation_stage(
|
|
|
133
133
|
try:
|
|
134
134
|
stage_base = stage_targets.resolve_stage_base_commit(
|
|
135
135
|
selected_stage,
|
|
136
|
-
|
|
136
|
+
snapshot.done_rows,
|
|
137
137
|
anchor_base_commit=anchor,
|
|
138
138
|
candidate_base=head_sha,
|
|
139
139
|
project_root=Path(task_worktree_path),
|
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Single product, single source of truth:
|
|
4
4
|
|
|
5
|
-
* ``
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
* ``build_report_view_model(src_md, *, run_meta)`` — deterministic
|
|
6
|
+
server-side Report View Model for human readers. It carries source digest,
|
|
7
|
+
rendered body HTML, response IDs, sidecar hints, and optional approval
|
|
8
|
+
context.
|
|
9
|
+
* ``render_report_view_model(model, *, css, js)`` — self-contained HTML
|
|
10
|
+
renderer over that model. Sections §1/§3/§4 user-actionable rows (those
|
|
11
|
+
reachable from §1 ``C-*`` IDs) get embedded ``<form>`` controls. §5.6 /
|
|
12
|
+
§5.7 / §5.8 deliverable sub-sections are explicitly excluded from form
|
|
13
|
+
attachment — they are read-only deliverables.
|
|
14
|
+
* ``render_html(src_md, *, run_meta)`` remains a compatibility wrapper around
|
|
15
|
+
the model builder + renderer.
|
|
10
16
|
|
|
11
17
|
User responses are NEVER merged back into the original report. The HTML
|
|
12
18
|
serialises a ``user-response`` markdown sidecar via ``Export user
|
|
@@ -101,34 +107,56 @@ class RunMeta:
|
|
|
101
107
|
source_report: str # relative path of the .md the HTML is derived from
|
|
102
108
|
|
|
103
109
|
|
|
104
|
-
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class ReportViewModel:
|
|
112
|
+
run_meta: RunMeta
|
|
113
|
+
title: str
|
|
114
|
+
body_html: str
|
|
115
|
+
source_digest: str
|
|
116
|
+
response_ids: tuple[str, ...]
|
|
117
|
+
sidecar_name: str
|
|
118
|
+
sidecar_dir: str
|
|
119
|
+
approval_ctx: PlanApprovalContext | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_report_view_model(
|
|
105
123
|
src_md: str,
|
|
106
124
|
*,
|
|
107
125
|
run_meta: RunMeta,
|
|
108
|
-
css: str,
|
|
109
|
-
js: str,
|
|
110
126
|
approval_ctx: PlanApprovalContext | None = None,
|
|
111
|
-
) ->
|
|
112
|
-
"""Return a single self-contained HTML document for ``src_md``.
|
|
113
|
-
|
|
114
|
-
``css`` / ``js`` are inlined verbatim. No external URLs are written
|
|
115
|
-
into the document (validator enforces this — see
|
|
116
|
-
validate-report-views.py).
|
|
117
|
-
"""
|
|
118
|
-
# Compute the digest over the source MD body. The validator
|
|
119
|
-
# recomputes the same digest from the current MD and compares it to
|
|
120
|
-
# the value embedded in run-meta below.
|
|
127
|
+
) -> ReportViewModel:
|
|
121
128
|
digest = source_digest(src_md)
|
|
122
129
|
body_md = _strip_leading_frontmatter(src_md)
|
|
123
130
|
body_html, toc_headings = _markdown_to_html(body_md)
|
|
124
131
|
body_html = _inject_toc(body_html, toc_headings)
|
|
125
|
-
response_ids =
|
|
132
|
+
response_ids = tuple(
|
|
133
|
+
sorted({item.row_id for item in (parse_clarification_items(body_md) or [])})
|
|
134
|
+
)
|
|
135
|
+
return ReportViewModel(
|
|
136
|
+
run_meta=run_meta,
|
|
137
|
+
title=f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}",
|
|
138
|
+
body_html=body_html,
|
|
139
|
+
source_digest=digest,
|
|
140
|
+
response_ids=response_ids,
|
|
141
|
+
sidecar_name=f"user-response-{run_meta.task_type}-{run_meta.seq}.md",
|
|
142
|
+
sidecar_dir=f"runs/{run_meta.task_type}/user-responses/",
|
|
143
|
+
approval_ctx=approval_ctx,
|
|
144
|
+
)
|
|
126
145
|
|
|
127
|
-
title = html.escape(f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}")
|
|
128
|
-
response_ids_json = "[" + ",".join('"' + html.escape(rid) + '"' for rid in response_ids) + "]"
|
|
129
|
-
sidecar_name = html.escape(f"user-response-{run_meta.task_type}-{run_meta.seq}.md")
|
|
130
|
-
sidecar_dir = html.escape(f"runs/{run_meta.task_type}/user-responses/")
|
|
131
146
|
|
|
147
|
+
def render_report_view_model(
|
|
148
|
+
model: ReportViewModel,
|
|
149
|
+
*,
|
|
150
|
+
css: str,
|
|
151
|
+
js: str,
|
|
152
|
+
) -> str:
|
|
153
|
+
title = html.escape(model.title)
|
|
154
|
+
response_ids_json = "[" + ",".join(
|
|
155
|
+
'"' + html.escape(rid) + '"' for rid in model.response_ids
|
|
156
|
+
) + "]"
|
|
157
|
+
sidecar_name = html.escape(model.sidecar_name)
|
|
158
|
+
sidecar_dir = html.escape(model.sidecar_dir)
|
|
159
|
+
run_meta = model.run_meta
|
|
132
160
|
return (
|
|
133
161
|
f"<!DOCTYPE html>\n"
|
|
134
162
|
f"<html lang=\"ko\">\n"
|
|
@@ -143,8 +171,8 @@ def render_html(
|
|
|
143
171
|
f" <div>{title}</div>\n"
|
|
144
172
|
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
145
173
|
f"</header>\n"
|
|
146
|
-
f"<main>{body_html}</main>\n"
|
|
147
|
-
f"{_plan_approval_section(approval_ctx, run_meta) if approval_ctx else ''}"
|
|
174
|
+
f"<main>{model.body_html}</main>\n"
|
|
175
|
+
f"{_plan_approval_section(model.approval_ctx, run_meta) if model.approval_ctx else ''}"
|
|
148
176
|
f"<footer class=\"report-footer\">\n"
|
|
149
177
|
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
150
178
|
f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
|
|
@@ -158,7 +186,7 @@ def render_html(
|
|
|
158
186
|
f"\"task-type\":\"{html.escape(run_meta.task_type)}\","
|
|
159
187
|
f"\"seq\":\"{html.escape(run_meta.seq)}\","
|
|
160
188
|
f"\"source-report\":\"{html.escape(run_meta.source_report)}\","
|
|
161
|
-
f"\"source-sha256\":\"{
|
|
189
|
+
f"\"source-sha256\":\"{model.source_digest}\","
|
|
162
190
|
f"\"response-ids\":{response_ids_json}}}"
|
|
163
191
|
f"</script>\n"
|
|
164
192
|
f"<script>{js}</script>\n"
|
|
@@ -167,6 +195,28 @@ def render_html(
|
|
|
167
195
|
)
|
|
168
196
|
|
|
169
197
|
|
|
198
|
+
def render_html(
|
|
199
|
+
src_md: str,
|
|
200
|
+
*,
|
|
201
|
+
run_meta: RunMeta,
|
|
202
|
+
css: str,
|
|
203
|
+
js: str,
|
|
204
|
+
approval_ctx: PlanApprovalContext | None = None,
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Return a single self-contained HTML document for ``src_md``.
|
|
207
|
+
|
|
208
|
+
``css`` / ``js`` are inlined verbatim. No external URLs are written
|
|
209
|
+
into the document (validator enforces this — see
|
|
210
|
+
validate-report-views.py).
|
|
211
|
+
"""
|
|
212
|
+
model = build_report_view_model(
|
|
213
|
+
src_md,
|
|
214
|
+
run_meta=run_meta,
|
|
215
|
+
approval_ctx=approval_ctx,
|
|
216
|
+
)
|
|
217
|
+
return render_report_view_model(model, css=css, js=js)
|
|
218
|
+
|
|
219
|
+
|
|
170
220
|
def _markdown_to_html(
|
|
171
221
|
src_md: str,
|
|
172
222
|
) -> tuple[str, list[tuple[int, str, str]]]:
|
|
@@ -38,6 +38,158 @@ class FinalVerificationTarget:
|
|
|
38
38
|
reports: list[str]
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class StageLifecycle:
|
|
43
|
+
stage: int
|
|
44
|
+
depends_on: list[int]
|
|
45
|
+
done: bool
|
|
46
|
+
started: bool
|
|
47
|
+
reserved: bool
|
|
48
|
+
verified_accepted: bool
|
|
49
|
+
pr_covered: bool
|
|
50
|
+
head_commit: str
|
|
51
|
+
report_path: str
|
|
52
|
+
|
|
53
|
+
def handoff_eligibility_record(self) -> dict[str, Any]:
|
|
54
|
+
reasons: list[str] = []
|
|
55
|
+
if self.pr_covered:
|
|
56
|
+
reasons.append("already-in-pr")
|
|
57
|
+
else:
|
|
58
|
+
if not self.done:
|
|
59
|
+
reasons.append("not-done")
|
|
60
|
+
if not self.verified_accepted:
|
|
61
|
+
reasons.append("not-verified-accepted")
|
|
62
|
+
return {
|
|
63
|
+
"stage": self.stage,
|
|
64
|
+
"depends_on": list(self.depends_on),
|
|
65
|
+
"eligible": not reasons,
|
|
66
|
+
"reasons": reasons,
|
|
67
|
+
"head_commit": self.head_commit,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class StageLifecycleSnapshot:
|
|
73
|
+
stage_map: list[dict[str, Any]]
|
|
74
|
+
rows: list[dict[str, Any]]
|
|
75
|
+
done_rows: list[dict[str, Any]]
|
|
76
|
+
done_by_stage: dict[int, dict[str, Any]]
|
|
77
|
+
done_stages: set[int]
|
|
78
|
+
started_stages: set[int]
|
|
79
|
+
reserved_stages: set[int]
|
|
80
|
+
verified_accepted_stages: set[int]
|
|
81
|
+
pr_covered_stages: set[int]
|
|
82
|
+
lifecycles: list[StageLifecycle]
|
|
83
|
+
|
|
84
|
+
def lifecycle_for(self, stage_number: int) -> StageLifecycle:
|
|
85
|
+
for lifecycle in self.lifecycles:
|
|
86
|
+
if lifecycle.stage == stage_number:
|
|
87
|
+
return lifecycle
|
|
88
|
+
raise StageTargetError(f"stage {stage_number} not in Stage Map")
|
|
89
|
+
|
|
90
|
+
def resolve_effective_stages(
|
|
91
|
+
self,
|
|
92
|
+
requested: str,
|
|
93
|
+
budget: int = RUN_STEP_BUDGET,
|
|
94
|
+
) -> list[int]:
|
|
95
|
+
return resolve_effective_stages(
|
|
96
|
+
self.stage_map,
|
|
97
|
+
self.done_stages,
|
|
98
|
+
requested,
|
|
99
|
+
budget=budget,
|
|
100
|
+
started_stages=self.started_stages,
|
|
101
|
+
reserved_stages=self.reserved_stages,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def resolve_implementation_stage(self, requested: str) -> int:
|
|
105
|
+
return self.resolve_effective_stages(requested)[0]
|
|
106
|
+
|
|
107
|
+
def concurrent_stage_numbers(self, *, selected_stage: int) -> list[int]:
|
|
108
|
+
return sorted(self.reserved_stages - self.done_stages - {selected_stage})
|
|
109
|
+
|
|
110
|
+
def handoff_eligibility(self) -> list[dict[str, Any]]:
|
|
111
|
+
return [
|
|
112
|
+
lifecycle.handoff_eligibility_record()
|
|
113
|
+
for lifecycle in self.lifecycles
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _stage_consumer_state_from_rows(rows: list[dict[str, Any]]) -> Any:
|
|
118
|
+
from .consumers import stage_consumer_state_from_rows
|
|
119
|
+
|
|
120
|
+
return stage_consumer_state_from_rows(rows)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def stage_lifecycle_snapshot_from_state(
|
|
124
|
+
stage_map: list[dict[str, Any]],
|
|
125
|
+
consumer_state: Any,
|
|
126
|
+
*,
|
|
127
|
+
reserved_stages: set[int] | None = None,
|
|
128
|
+
) -> StageLifecycleSnapshot:
|
|
129
|
+
reserved = set(reserved_stages or set())
|
|
130
|
+
lifecycles: list[StageLifecycle] = []
|
|
131
|
+
for stage in stage_map:
|
|
132
|
+
stage_number = stage["stage_number"]
|
|
133
|
+
done_row = consumer_state.done_by_stage.get(stage_number) or {}
|
|
134
|
+
lifecycles.append(StageLifecycle(
|
|
135
|
+
stage=stage_number,
|
|
136
|
+
depends_on=list(stage.get("depends_on") or []),
|
|
137
|
+
done=stage_number in consumer_state.done_stages,
|
|
138
|
+
started=stage_number in consumer_state.started_stages,
|
|
139
|
+
reserved=stage_number in reserved,
|
|
140
|
+
verified_accepted=stage_number in (
|
|
141
|
+
consumer_state.verified_accepted_stages
|
|
142
|
+
),
|
|
143
|
+
pr_covered=stage_number in consumer_state.pr_covered_stages,
|
|
144
|
+
head_commit=str(done_row.get("head_commit") or ""),
|
|
145
|
+
report_path=str(done_row.get("report_path") or ""),
|
|
146
|
+
))
|
|
147
|
+
return StageLifecycleSnapshot(
|
|
148
|
+
stage_map=stage_map,
|
|
149
|
+
rows=list(consumer_state.rows),
|
|
150
|
+
done_rows=list(consumer_state.done_rows),
|
|
151
|
+
done_by_stage=dict(consumer_state.done_by_stage),
|
|
152
|
+
done_stages=set(consumer_state.done_stages),
|
|
153
|
+
started_stages=set(consumer_state.started_stages),
|
|
154
|
+
reserved_stages=reserved,
|
|
155
|
+
verified_accepted_stages=set(consumer_state.verified_accepted_stages),
|
|
156
|
+
pr_covered_stages=set(consumer_state.pr_covered_stages),
|
|
157
|
+
lifecycles=lifecycles,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def stage_lifecycle_snapshot_from_rows(
|
|
162
|
+
stage_map: list[dict[str, Any]],
|
|
163
|
+
rows: list[dict[str, Any]],
|
|
164
|
+
*,
|
|
165
|
+
reserved_stages: set[int] | None = None,
|
|
166
|
+
) -> StageLifecycleSnapshot:
|
|
167
|
+
return stage_lifecycle_snapshot_from_state(
|
|
168
|
+
stage_map,
|
|
169
|
+
_stage_consumer_state_from_rows(rows),
|
|
170
|
+
reserved_stages=reserved_stages,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def read_stage_lifecycle_snapshot(
|
|
175
|
+
stage_map: list[dict[str, Any]],
|
|
176
|
+
plan_run_root: Path,
|
|
177
|
+
*,
|
|
178
|
+
recover_from_carry: bool = False,
|
|
179
|
+
reserved_stages: set[int] | None = None,
|
|
180
|
+
) -> StageLifecycleSnapshot:
|
|
181
|
+
from .consumers import read_stage_consumer_state
|
|
182
|
+
|
|
183
|
+
return stage_lifecycle_snapshot_from_state(
|
|
184
|
+
stage_map,
|
|
185
|
+
read_stage_consumer_state(
|
|
186
|
+
plan_run_root,
|
|
187
|
+
recover_from_carry=recover_from_carry,
|
|
188
|
+
),
|
|
189
|
+
reserved_stages=reserved_stages,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
41
193
|
def resolve_effective_stages(
|
|
42
194
|
stages: list[dict[str, Any]],
|
|
43
195
|
done_stages: set[int],
|
|
@@ -12,6 +12,7 @@ Public surface:
|
|
|
12
12
|
- ``next_prompt()`` - deterministic; pure read on state.
|
|
13
13
|
- ``submit()`` - validate + advance.
|
|
14
14
|
- ``render_args()`` - final args for ``okstra render-bundle``.
|
|
15
|
+
- ``wizard_outcome()`` - final args plus persistence actions and confirmation.
|
|
15
16
|
|
|
16
17
|
The skill calls these via the ``okstra wizard`` CLI subcommand; it never
|
|
17
18
|
imports this module directly.
|
|
@@ -3707,6 +3708,36 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
3707
3708
|
return "\n".join(lines)
|
|
3708
3709
|
|
|
3709
3710
|
|
|
3711
|
+
def _wizard_persist_actions(state: WizardState) -> list[dict[str, str]]:
|
|
3712
|
+
if state.task_type != "release-handoff":
|
|
3713
|
+
return []
|
|
3714
|
+
if not state.pr_template_path:
|
|
3715
|
+
return []
|
|
3716
|
+
if state.pr_template_scope not in ("project", "global"):
|
|
3717
|
+
return []
|
|
3718
|
+
return [
|
|
3719
|
+
{
|
|
3720
|
+
"command": "config.set",
|
|
3721
|
+
"key": "pr-template-path",
|
|
3722
|
+
"scope": state.pr_template_scope,
|
|
3723
|
+
"value": state.pr_template_path,
|
|
3724
|
+
}
|
|
3725
|
+
]
|
|
3726
|
+
|
|
3727
|
+
|
|
3728
|
+
def wizard_outcome(state: WizardState) -> dict[str, Any]:
|
|
3729
|
+
"""Public outcome for callers that need launch data and follow-up writes."""
|
|
3730
|
+
if state.aborted:
|
|
3731
|
+
raise WizardError("wizard was aborted by the user — outcome is unavailable")
|
|
3732
|
+
if state.confirmed is not True:
|
|
3733
|
+
raise WizardError("wizard is not complete — outcome is unavailable")
|
|
3734
|
+
return {
|
|
3735
|
+
"renderArgs": render_args(state),
|
|
3736
|
+
"persistActions": _wizard_persist_actions(state),
|
|
3737
|
+
"confirmationText": confirmation_block(state),
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
|
|
3710
3741
|
# ---- File I/O helpers (used by CLI) -------------------------------------
|
|
3711
3742
|
|
|
3712
3743
|
def load_state_file(path: Path) -> WizardState:
|
|
@@ -3731,6 +3762,7 @@ def _cli(argv: list[str]) -> int:
|
|
|
3731
3762
|
step --state-file PATH (--answer VALUE | --no-submit)
|
|
3732
3763
|
render-args --state-file PATH
|
|
3733
3764
|
confirmation --state-file PATH
|
|
3765
|
+
outcome --state-file PATH
|
|
3734
3766
|
"""
|
|
3735
3767
|
import argparse
|
|
3736
3768
|
|
|
@@ -3759,6 +3791,9 @@ def _cli(argv: list[str]) -> int:
|
|
|
3759
3791
|
p_conf = sub.add_parser("confirmation")
|
|
3760
3792
|
p_conf.add_argument("--state-file", required=True)
|
|
3761
3793
|
|
|
3794
|
+
p_outcome = sub.add_parser("outcome")
|
|
3795
|
+
p_outcome.add_argument("--state-file", required=True)
|
|
3796
|
+
|
|
3762
3797
|
args = parser.parse_args(argv)
|
|
3763
3798
|
state_path = Path(args.state_file)
|
|
3764
3799
|
|
|
@@ -3836,6 +3871,18 @@ def _cli(argv: list[str]) -> int:
|
|
|
3836
3871
|
ensure_ascii=False, indent=2))
|
|
3837
3872
|
return 0
|
|
3838
3873
|
|
|
3874
|
+
if args.cmd == "outcome":
|
|
3875
|
+
state = load_state_file(state_path)
|
|
3876
|
+
try:
|
|
3877
|
+
out = wizard_outcome(state)
|
|
3878
|
+
except WizardError as exc:
|
|
3879
|
+
print(json.dumps({"ok": False, "error": str(exc)},
|
|
3880
|
+
ensure_ascii=False, indent=2))
|
|
3881
|
+
return 0
|
|
3882
|
+
print(json.dumps({"ok": True, "outcome": out},
|
|
3883
|
+
ensure_ascii=False, indent=2))
|
|
3884
|
+
return 0
|
|
3885
|
+
|
|
3839
3886
|
return 2
|
|
3840
3887
|
|
|
3841
3888
|
|