okstra 0.221.0 → 0.223.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/architecture/storage-model.md +1 -1
- package/docs/architecture.md +5 -5
- package/docs/cli.md +7 -7
- package/docs/project-structure-overview.md +4 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/phase-routing.md +1 -1
- package/runtime/python/okstra_ctl/analysis_packet.py +8 -6
- package/runtime/python/okstra_ctl/business_flow/engine.py +1 -0
- package/runtime/python/okstra_ctl/convergence.py +155 -19
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +3 -2
- package/runtime/python/okstra_ctl/convergence_migration.py +10 -1
- package/runtime/python/okstra_ctl/convergence_provenance.py +69 -24
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -1
- package/runtime/python/okstra_ctl/correction_cli.py +7 -7
- package/runtime/python/okstra_ctl/correction_round.py +40 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +14 -1
- package/runtime/python/okstra_ctl/git_reconcile.py +62 -8
- package/runtime/python/okstra_ctl/next_phase.py +22 -9
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +4 -2
- package/runtime/python/okstra_ctl/phases/implementation/entry.py +6 -2
- package/runtime/python/okstra_ctl/phases/implementation/host-rules.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +2 -2
- package/runtime/python/okstra_ctl/phases/release_handoff/entry.py +42 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +52 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +3 -2
- package/runtime/python/okstra_ctl/phases/release_handoff/report_assets/release-handoff-input.template.md +3 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
- package/runtime/python/okstra_ctl/process_group.py +4 -0
- package/runtime/python/okstra_ctl/report_assembly.py +50 -13
- package/runtime/python/okstra_ctl/report_html/common.py +3 -1
- package/runtime/python/okstra_ctl/report_projections.py +46 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +22 -1
- package/runtime/python/okstra_ctl/run.py +8 -5
- package/runtime/python/okstra_ctl/stage_reconcile.py +49 -1
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -3
- package/runtime/python/okstra_ctl/worktree/decisions.py +42 -5
- package/runtime/python/okstra_ctl/worktree/provision.py +49 -2
- package/runtime/schemas/final-report-v2.0.schema.json +7 -2
- package/runtime/schemas/final-report-v3.0.schema.json +43 -2
- package/runtime/skills/okstra-run/SKILL.md +1 -0
- package/runtime/templates/reports/html/base.template.html +6 -1
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/validators/validate-run.py +10 -6
- package/runtime/validators/validate_session_conformance.py +19 -1
|
@@ -12,7 +12,10 @@ from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
|
|
|
12
12
|
from okstra_ctl.stage_map import parse_stage_map_for_prepare
|
|
13
13
|
from okstra_ctl.phases.catalog import migrated_report_template
|
|
14
14
|
from okstra_ctl.handoff_error import HandoffError
|
|
15
|
-
from .operations import
|
|
15
|
+
from .operations import (
|
|
16
|
+
DEFAULT_RELEASE_BASES, _parse_stages_csv, _require_eligible,
|
|
17
|
+
anchor_base_branches, compute_eligibility,
|
|
18
|
+
)
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
def _collect_handoff_source_report_rows(
|
|
@@ -74,6 +77,43 @@ def _select_handoff_stages(stage_map: list, rows: list, selection: str) -> list[
|
|
|
74
77
|
return nums
|
|
75
78
|
|
|
76
79
|
|
|
80
|
+
def _render_anchor_base_candidates(project_root: Path, inp) -> str:
|
|
81
|
+
"""구현 기준 커밋을 담은 기본 후보가 없으면, 그 커밋에 있는 origin 브랜치를
|
|
82
|
+
Q2 의 첫 후보로 올린다. 다른 task 의 미병합 stage 위에 쌓인 task 는 기본
|
|
83
|
+
후보 어느 것을 base 로 잡아도 그 task 의 커밋까지 PR 에 실린다."""
|
|
84
|
+
from okstra_ctl import worktree_registry
|
|
85
|
+
from okstra_ctl.work_categories import resolve_work_category
|
|
86
|
+
from okstra_ctl.worktree import is_ancestor
|
|
87
|
+
|
|
88
|
+
anchor = worktree_registry.get_implementation_base(
|
|
89
|
+
inp.project_id, inp.task_group, inp.task_id) or ""
|
|
90
|
+
if not anchor:
|
|
91
|
+
return ""
|
|
92
|
+
if any(is_ancestor(project_root, anchor, f"refs/remotes/origin/{name}")
|
|
93
|
+
for name in DEFAULT_RELEASE_BASES):
|
|
94
|
+
return ""
|
|
95
|
+
work_category = resolve_work_category(
|
|
96
|
+
inp.work_category, project_root=project_root,
|
|
97
|
+
task_group=inp.task_group, task_id=inp.task_id)
|
|
98
|
+
branches = anchor_base_branches(project_root, anchor,
|
|
99
|
+
work_category=work_category, task_id=inp.task_id)
|
|
100
|
+
held = branches["at"] + branches["containing"]
|
|
101
|
+
defaults = ", ".join(f"`{name}`" for name in DEFAULT_RELEASE_BASES)
|
|
102
|
+
lines = [f"- Implementation anchor base: `{anchor}` — no default candidate "
|
|
103
|
+
f"({defaults}) contains it, so a PR on any of them would also carry "
|
|
104
|
+
"the commits of the work this task was built on."]
|
|
105
|
+
if held:
|
|
106
|
+
lines.append(f"- Recommended release base (offer first in Q2): `{held[0]}`")
|
|
107
|
+
else:
|
|
108
|
+
lines.append("- Recommended release base: none — no origin branch holds the "
|
|
109
|
+
"anchor; push the branch it came from before release-handoff.")
|
|
110
|
+
lines.append("- origin branches at the anchor: "
|
|
111
|
+
+ (", ".join(f"`{n}`" for n in branches["at"]) or "none"))
|
|
112
|
+
lines.append("- origin branches containing the anchor: "
|
|
113
|
+
+ (", ".join(f"`{n}`" for n in branches["containing"]) or "none"))
|
|
114
|
+
return "\n".join(lines)
|
|
115
|
+
|
|
116
|
+
|
|
77
117
|
def materialize_handoff_input(
|
|
78
118
|
workspace_root: Path, project_root: Path, inp, *, install_hint: str,
|
|
79
119
|
) -> dict:
|
|
@@ -133,6 +173,7 @@ def materialize_handoff_input(
|
|
|
133
173
|
"HANDOFF_STAGES": stages_csv,
|
|
134
174
|
"HANDOFF_SOURCE_REPORTS": source_rows,
|
|
135
175
|
"HANDOFF_CONDITIONS": source_conditions,
|
|
176
|
+
"HANDOFF_ANCHOR_BASE_CANDIDATES": _render_anchor_base_candidates(project_root, inp),
|
|
136
177
|
})
|
|
137
178
|
inp.brief_path = out
|
|
138
179
|
return {"HANDOFF_STAGES": stages_csv}
|
|
@@ -123,6 +123,51 @@ def _merge_base_for(*, project_root, project_id, task_group, task_id,
|
|
|
123
123
|
raise HandoffError(str(exc)) from exc
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
# 입력 문서 `Candidate Release Base Branches` 의 기본 후보와 같은 목록이다.
|
|
127
|
+
DEFAULT_RELEASE_BASES = ("staging", "preprod", "prod", "main", "dev")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _origin_branches(project_root, flag: str, commit: str, own_prefix: str) -> List[str]:
|
|
131
|
+
"""`git branch -r <flag> <commit>` 중 origin 브랜치 이름. 이 task 자신의 stage
|
|
132
|
+
브랜치는 릴리스 base 가 될 수 없으므로 뺀다."""
|
|
133
|
+
res = _git(Path(project_root), "branch", "-r", "--format=%(refname)", flag, commit)
|
|
134
|
+
if res.returncode != 0:
|
|
135
|
+
return []
|
|
136
|
+
prefix = "refs/remotes/origin/"
|
|
137
|
+
names = [line.strip()[len(prefix):] for line in res.stdout.splitlines()
|
|
138
|
+
if line.strip().startswith(prefix)]
|
|
139
|
+
return [n for n in names if n != "HEAD"
|
|
140
|
+
and n != own_prefix and not n.startswith(own_prefix + "-")]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def anchor_base_branches(project_root, anchor: str, *, work_category: str,
|
|
144
|
+
task_id: str) -> Dict[str, List[str]]:
|
|
145
|
+
"""구현 기준 커밋을 tip 으로 가진 origin 브랜치와 그 커밋을 담은 브랜치."""
|
|
146
|
+
own = compute_branch_name(work_category=work_category, task_id_segment=task_id)
|
|
147
|
+
at = _origin_branches(project_root, "--points-at", anchor, own)
|
|
148
|
+
containing = [n for n in _origin_branches(project_root, "--contains", anchor, own)
|
|
149
|
+
if n not in at]
|
|
150
|
+
return {"at": at, "containing": containing}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _foreign_anchor_error(*, stage: int, base_branch: str, anchor: str,
|
|
154
|
+
branches: Dict[str, List[str]]) -> HandoffError:
|
|
155
|
+
held = branches["at"] + branches["containing"]
|
|
156
|
+
if held:
|
|
157
|
+
fix = f"re-run pr-plan with --base {held[0]}"
|
|
158
|
+
else:
|
|
159
|
+
fix = ("no origin branch holds that commit — push the branch it came "
|
|
160
|
+
f"from, or wait until it is merged into origin/{base_branch}")
|
|
161
|
+
return HandoffError(
|
|
162
|
+
f"stage {stage} would target origin/{base_branch}, but this task was "
|
|
163
|
+
f"implemented on {anchor}, which origin/{base_branch} does not contain "
|
|
164
|
+
"— the PR would also carry every commit between them, which belong to "
|
|
165
|
+
"other work. pr-plan derives stacking within this task only; a task "
|
|
166
|
+
"built on another task's unmerged branch needs that branch as its "
|
|
167
|
+
f"release base. origin branches at {anchor}: {branches['at'] or 'none'}; "
|
|
168
|
+
f"containing it: {branches['containing'] or 'none'}. {fix}")
|
|
169
|
+
|
|
170
|
+
|
|
126
171
|
def _resolve_stage_base(
|
|
127
172
|
*, stage: int, deps: List[int], selected: List[int], pr_covered: set,
|
|
128
173
|
done: Dict[int, Dict[str, Any]], project_root, base_branch: str,
|
|
@@ -135,6 +180,13 @@ def _resolve_stage_base(
|
|
|
135
180
|
if not is_ancestor(project_root, _done_head(done, d),
|
|
136
181
|
f"origin/{base_branch}")]
|
|
137
182
|
if not live:
|
|
183
|
+
if anchor_base and not is_ancestor(project_root, anchor_base,
|
|
184
|
+
f"origin/{base_branch}"):
|
|
185
|
+
raise _foreign_anchor_error(
|
|
186
|
+
stage=stage, base_branch=base_branch, anchor=anchor_base,
|
|
187
|
+
branches=anchor_base_branches(
|
|
188
|
+
project_root, anchor_base, work_category=work_category,
|
|
189
|
+
task_id=task_id))
|
|
138
190
|
commit = _run_git(["rev-parse", f"origin/{base_branch}"],
|
|
139
191
|
project_root).stdout.strip()
|
|
140
192
|
return {"base_kind": "release-base", "base_branch": base_branch,
|
|
@@ -31,13 +31,14 @@
|
|
|
31
31
|
`okstra handoff local-checkout --project-root <project root> --project-id <id> --task-group <g> --task-id <t> --stage <N>`
|
|
32
32
|
Exit 1 means a precondition failed (MAIN worktree dirty, registry entry missing, a run still holds the stage, the target branch already deleted, or checkout failed): show the error verbatim and re-ask the action selection. On success the okstra worktree for that stage no longer exists — if the lead's cwd was inside it, EVERY subsequent step (final-report authoring included) MUST use absolute paths or run from the MAIN worktree. Then route to the final-report self-review pass.
|
|
33
33
|
2. **Release base branch** (only when the user picked `push + PR`) — present these options and capture exactly one:
|
|
34
|
+
- the `Recommended release base` from the input document's `## Candidate Release Base Branches`, when that section names one — first, marked recommended
|
|
34
35
|
- `preprod`
|
|
35
36
|
- `main`
|
|
36
37
|
- `direct input` (free-form branch name; lead validates the name exists on origin via `git ls-remote --heads origin <name>` and re-asks on failure)
|
|
37
|
-
This is the release base for the run, not necessarily each PR's base: a stage that sits on another stage targets that stage's branch. The chosen base MUST NOT be one of
|
|
38
|
+
This is the release base for the run, not necessarily each PR's base: a stage that sits on another stage of this task targets that stage's branch. The release base MUST contain the task's implementation anchor base. `pr-plan` derives stacking inside this task only; when the task was built on another task's unmerged stage branch, the release base is that predecessor task's stage branch, which prepare names as the recommended base. The chosen base MUST NOT be one of this task's stage branches. If it is, re-ask.
|
|
38
39
|
2p. **PR plan** (only when the user picked `push + PR`) — run
|
|
39
40
|
`okstra handoff pr-plan --plan-run-root <...> --approved-plan <...> --project-root <project root> --project-id <id> --task-group <g> --task-id <t> --work-category <c> --stages <HANDOFF_STAGES> --base <chosen-base>`.
|
|
40
|
-
It returns one row per stage with `head_branch`, `head_commit`, `base_kind` (`release-base` | `stage` | `merge-base`), `base_branch` and `base_commit`. Exit 2 means the predecessors of a multi-dependency stage conflict with each other: show the `conflicts` paths and stop (route: resolve manually, then retry). Exit 1 means an eligibility or dependency violation — most often a stage whose predecessor is neither in this run nor already PR'd nor merged into the release base: show the error verbatim and re-ask the action selection. On success these rows are authoritative for every subsequent step; the lead does not recompute a base by hand.
|
|
41
|
+
It returns one row per stage with `head_branch`, `head_commit`, `base_kind` (`release-base` | `stage` | `merge-base`), `base_branch` and `base_commit`. Exit 2 means the predecessors of a multi-dependency stage conflict with each other: show the `conflicts` paths and stop (route: resolve manually, then retry). Exit 1 means an eligibility or dependency violation — most often a stage whose predecessor is neither in this run nor already PR'd nor merged into the release base: show the error verbatim and re-ask the action selection. When the error says the release base does not contain the implementation anchor, the error names the `--base` that does: show it verbatim and re-ask Q2 with that branch first. **Enforced:** `okstra_ctl.phases.release_handoff.operations._resolve_stage_base` refuses the plan. On success these rows are authoritative for every subsequent step; the lead does not recompute a base by hand.
|
|
41
42
|
A `merge-base` row means `pr-plan` created a branch merging that stage's predecessors. That branch is a PR base only — it is never a PR head — and it MUST be pushed before the PR that targets it.
|
|
42
43
|
The output also carries `push_plan` and `warnings`. `push_plan` is every branch the PRs sit on, already in push order (each `merge-base` first, then the stage heads in dependency order), with the `commit` it must reach and an `action` of `create` (origin does not have it), `fast-forward` (origin is behind) or `up-to-date` (origin already has that commit — do not push it). GitHub resolves a PR's head and base by name on origin, so this list, not the local branch set, is what makes the stack real. A `warnings` row means `pr-plan` fast-forwarded a local branch to origin because origin was ahead: the commits origin adds are outside what this run verified. Quote every `warnings` row verbatim in Executed Commands and say so in the branch/PR explanation — the user is deciding whether to release commits this run did not verify. `pr-plan` refuses outright when a branch moved outside okstra or diverged from origin, since only a force push could deliver it.
|
|
43
44
|
2b. **Pre-merge conflict probe** (only when the user picked `push + PR`) — before pushing, for each planned stage in dependency order:
|
|
@@ -48,7 +48,9 @@ taskType: "{{FM_TASK_TYPE}}"
|
|
|
48
48
|
|
|
49
49
|
## Candidate Release Base Branches
|
|
50
50
|
|
|
51
|
-
The user picks ONE release base for the whole run. Each stage's own PR base is then derived from its `depends-on` by `okstra handoff pr-plan`: a stage with no live dependency targets the release base, a stage with one targets that stage's branch (a stacked PR), and a stage with several targets a branch merging them.
|
|
51
|
+
The user picks ONE release base for the whole run. Each stage's own PR base is then derived from its `depends-on` by `okstra handoff pr-plan`: a stage with no live dependency targets the release base, a stage with one targets that stage's branch (a stacked PR), and a stage with several targets a branch merging them. That derivation covers stacking inside this task only. The release base must contain the task's implementation anchor base; when this task was built on another task's unmerged stage branch, the release base is that branch, and `pr-plan` refuses a base that lacks the anchor.
|
|
52
|
+
|
|
53
|
+
{{HANDOFF_ANCHOR_BASE_CANDIDATES}}
|
|
52
54
|
|
|
53
55
|
- Default options offered to the user: `staging` | `preprod` | `prod` | `main` | `dev` | custom input
|
|
54
56
|
- Repo-specific preference, if known (e.g. `main` is the integration branch):
|
|
@@ -141,7 +141,7 @@ stateDiagram-v2
|
|
|
141
141
|
User interaction is exactly three steps.
|
|
142
142
|
|
|
143
143
|
1. Q1 action: `local checkout`, `push + PR`, `skip`
|
|
144
|
-
2. Q2 release base: a branch from the profile menu such as `staging`, `preprod`, `main`, or Enter directly
|
|
144
|
+
2. Q2 release base: a branch from the profile menu such as `staging`, `preprod`, `main`, or Enter directly. When no default candidate contains the task's implementation anchor base (the task was built on another task's unmerged stage branch), prepare names the origin branch at the anchor as the recommended first option, and `pr-plan` refuses a release base that lacks the anchor
|
|
145
145
|
3. Q3 PR title/body: every stage's draft in one question — `use as-is`, `edit then proceed`, `cancel`
|
|
146
146
|
|
|
147
147
|
The merge-conflict probe happens only for `push + PR`, once per stage against that stage's own base.
|
|
@@ -84,6 +84,10 @@ def kill_group(pgid: int) -> bool:
|
|
|
84
84
|
os.killpg(pgid, signal.SIGKILL)
|
|
85
85
|
except ProcessLookupError:
|
|
86
86
|
return False
|
|
87
|
+
except PermissionError:
|
|
88
|
+
# expected-miss: macOS 는 남은 구성원이 아직 거둬지지 않은 좀비뿐인 그룹에 EPERM 을
|
|
89
|
+
# 준다. 죽일 살아 있는 프로세스가 없다는 뜻이라, 끝난 워커의 결과를 버릴 이유가 아니다.
|
|
90
|
+
return False
|
|
87
91
|
return True
|
|
88
92
|
|
|
89
93
|
|
|
@@ -18,6 +18,7 @@ from .clarification_items import (
|
|
|
18
18
|
)
|
|
19
19
|
from .final_report_schema import load_schema_version, validate
|
|
20
20
|
from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
|
|
21
|
+
from .correction_round import correction_convergence_states, correction_round
|
|
21
22
|
from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
|
|
22
23
|
from .report_narrative import parse_narrative, task_narrative_errors
|
|
23
24
|
from .report_synthesis_packet import (
|
|
@@ -98,6 +99,32 @@ def _read_json(row: ReportInputPath) -> dict[str, Any]:
|
|
|
98
99
|
return value
|
|
99
100
|
|
|
100
101
|
|
|
102
|
+
def _correction_round_convergence(
|
|
103
|
+
initial: ReportInputPath, cross: Mapping[str, Any],
|
|
104
|
+
) -> tuple[list[dict[str, Any]], list[tuple[str, Mapping[str, Any]]]]:
|
|
105
|
+
"""Each correction round's re-convergence, projected like the initial one, and the
|
|
106
|
+
evidence it promotes labelled `convergence <scope>`."""
|
|
107
|
+
rows: list[dict[str, Any]] = []
|
|
108
|
+
promoted: list[tuple[str, Mapping[str, Any]]] = []
|
|
109
|
+
consensus = len(cross["consensus"])
|
|
110
|
+
differences = len(cross["differences"])
|
|
111
|
+
for scope, path in correction_convergence_states(initial.path):
|
|
112
|
+
row = ReportInputPath(initial.key, initial.owner, path)
|
|
113
|
+
try:
|
|
114
|
+
result = project_convergence(
|
|
115
|
+
_read_json(row),
|
|
116
|
+
first_consensus=consensus + 1, first_difference=differences + 1,
|
|
117
|
+
)
|
|
118
|
+
except ReportProjectionError as exc:
|
|
119
|
+
_fail(row.owner, path, "$", exc)
|
|
120
|
+
projected = result["crossVerification"]
|
|
121
|
+
consensus += len(projected["consensus"])
|
|
122
|
+
differences += len(projected["differences"])
|
|
123
|
+
rows.append({"round": correction_round(scope), **projected})
|
|
124
|
+
promoted.extend((f"convergence {scope}", item) for item in result["promotedEvidence"])
|
|
125
|
+
return rows, promoted
|
|
126
|
+
|
|
127
|
+
|
|
101
128
|
def _input_map(project_root: Path, manifest: Mapping[str, Any]) -> dict[str, ReportInputPath]:
|
|
102
129
|
try:
|
|
103
130
|
return {row.key: row for row in report_input_paths(project_root, manifest)}
|
|
@@ -488,6 +515,11 @@ def _compose(
|
|
|
488
515
|
data = {"schemaVersion": "3.0", **narrative, **execution}
|
|
489
516
|
data["tokenUsage"] = usage
|
|
490
517
|
data["crossVerification"] = convergence_data["crossVerification"]
|
|
518
|
+
rounds, round_evidence = _correction_round_convergence(
|
|
519
|
+
inputs["convergence"], data["crossVerification"]
|
|
520
|
+
)
|
|
521
|
+
if rounds:
|
|
522
|
+
data["crossVerification"]["correctionRounds"] = rounds
|
|
491
523
|
data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
|
|
492
524
|
# 작성자의 사실 분류와 리드 원장의 질문 연결은 양쪽 입력을 합친 뒤 검사한다.
|
|
493
525
|
for reason in validate_blocked_answer_channel(data):
|
|
@@ -504,8 +536,13 @@ def _compose(
|
|
|
504
536
|
if scope:
|
|
505
537
|
data["verificationScope"] = scope
|
|
506
538
|
data["agentActivity"] = activities
|
|
507
|
-
data["evidence"] = {"primary": _promoted_evidence(
|
|
508
|
-
|
|
539
|
+
data["evidence"] = {"primary": _promoted_evidence([
|
|
540
|
+
*(("convergence", item) for item in convergence_data["promotedEvidence"]),
|
|
541
|
+
*round_evidence,
|
|
542
|
+
])}
|
|
543
|
+
data["missingInformation"] = _missing_information(
|
|
544
|
+
project_root, manifest, convergence_data["criticUnverified"],
|
|
545
|
+
)
|
|
509
546
|
# 스냅샷 복사가 _attach_metadata 보다 먼저다 — frontmatter 가
|
|
510
547
|
# selectedDirectionRef.snapshotPath 를 읽는다.
|
|
511
548
|
_apply_selected_direction_snapshot(data, project_root, manifest)
|
|
@@ -529,16 +566,16 @@ def _attach_discussion(data: dict[str, Any], project_root: Path, manifest: Mappi
|
|
|
529
566
|
_fail("discussion-record", store.records_path, "discussion", exc)
|
|
530
567
|
|
|
531
568
|
|
|
532
|
-
def
|
|
533
|
-
project_root: Path, manifest: Mapping[str, Any],
|
|
569
|
+
def _missing_information(
|
|
570
|
+
project_root: Path, manifest: Mapping[str, Any], critic_unverified: list[dict[str, Any]],
|
|
534
571
|
) -> list[dict[str, Any]]:
|
|
535
|
-
"""Coverage-census warnings are the only rows this
|
|
536
|
-
v3 contract, so they number from `R-001`."""
|
|
537
|
-
state = read_census_state(project_root, manifest)
|
|
538
|
-
if state is None:
|
|
539
|
-
return []
|
|
572
|
+
"""Coverage-census warnings and unjudged critic gaps are the only rows this
|
|
573
|
+
field carries under the v3 contract, so they number from `R-001`."""
|
|
540
574
|
_project_id, _task_group, task_id = _identity(manifest)
|
|
541
|
-
|
|
575
|
+
ticket_id = task_id or "unknown"
|
|
576
|
+
state = read_census_state(project_root, manifest)
|
|
577
|
+
rows = [] if state is None else census_warning_rows(state, ticket_id=ticket_id)
|
|
578
|
+
rows += [{**row, "ticketId": row["ticketId"] or ticket_id} for row in critic_unverified]
|
|
542
579
|
return [{"id": f"R-{index:03d}", **row} for index, row in enumerate(rows, start=1)]
|
|
543
580
|
|
|
544
581
|
|
|
@@ -717,16 +754,16 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
|
|
|
717
754
|
}
|
|
718
755
|
|
|
719
756
|
|
|
720
|
-
def _promoted_evidence(
|
|
757
|
+
def _promoted_evidence(rows: list[tuple[str, Mapping[str, Any]]]) -> list[dict[str, Any]]:
|
|
721
758
|
return [
|
|
722
759
|
{
|
|
723
760
|
"id": f"E-{index:03d}",
|
|
724
761
|
"ticketId": row["ticketId"],
|
|
725
762
|
"evidence": row["evidence"],
|
|
726
763
|
"sourceItems": row["sourceItems"],
|
|
727
|
-
"source":
|
|
764
|
+
"source": source,
|
|
728
765
|
}
|
|
729
|
-
for index, row in enumerate(
|
|
766
|
+
for index, (source, row) in enumerate(rows, start=1)
|
|
730
767
|
]
|
|
731
768
|
|
|
732
769
|
|
|
@@ -403,8 +403,10 @@ def anchor_index(
|
|
|
403
403
|
for row_id in sorted(found)
|
|
404
404
|
if isinstance(row_id, str) and _anchorable(row_id)
|
|
405
405
|
}
|
|
406
|
+
cross = data.get("crossVerification") if isinstance(data.get("crossVerification"), dict) else {}
|
|
407
|
+
rounds = [cross, *(r for r in cross.get("correctionRounds") or [] if isinstance(r, dict))]
|
|
406
408
|
for block in ("consensus", "differences"):
|
|
407
|
-
for row in
|
|
409
|
+
for row in (row for scope in rounds for row in scope.get(block) or []):
|
|
408
410
|
row_id = row.get("id") if isinstance(row, dict) else None
|
|
409
411
|
if isinstance(row_id, str) and _anchorable(row_id) and row_id not in index:
|
|
410
412
|
index[row_id] = f"id-xv-{row_id}"
|
|
@@ -261,8 +261,14 @@ def _source_items(finding: Mapping[str, Any]) -> list[str]:
|
|
|
261
261
|
return [f"{_text(worker, 'worker').removesuffix('-worker')}:{finding_id}" for worker in workers]
|
|
262
262
|
|
|
263
263
|
|
|
264
|
-
def project_convergence(
|
|
265
|
-
|
|
264
|
+
def project_convergence(
|
|
265
|
+
state: Mapping[str, Any], *, first_consensus: int = 1, first_difference: int = 1,
|
|
266
|
+
) -> dict[str, Any]:
|
|
267
|
+
"""수렴 상태에서 독자용 합의·이견과 공개 근거만 승격한다.
|
|
268
|
+
|
|
269
|
+
정정 라운드 재수렴은 초기 행 다음 번호부터 매긴다 — 행 id 가 리포트 앵커라 한 리포트
|
|
270
|
+
안에서 겹치면 안 된다.
|
|
271
|
+
"""
|
|
266
272
|
consensus = []
|
|
267
273
|
differences = []
|
|
268
274
|
promoted = []
|
|
@@ -274,7 +280,7 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
274
280
|
ticket_id = _text(ticket_ids[0] if ticket_ids else "unknown", "unknown")
|
|
275
281
|
if finding.get("classification") in {"full-consensus", "partial-consensus"}:
|
|
276
282
|
row = {
|
|
277
|
-
"id": f"CV-{len(consensus) +
|
|
283
|
+
"id": f"CV-{len(consensus) + first_consensus:03d}",
|
|
278
284
|
"ticketId": ticket_id,
|
|
279
285
|
"statement": _text(finding.get("summary"), "Converged finding"),
|
|
280
286
|
"sourceItems": _source_items(finding),
|
|
@@ -298,7 +304,7 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
298
304
|
"position": "origin" if worker == finding.get("originWorker") else "dissent",
|
|
299
305
|
})
|
|
300
306
|
differences.append({
|
|
301
|
-
"id": f"D-{len(differences) +
|
|
307
|
+
"id": f"D-{len(differences) + first_difference:03d}",
|
|
302
308
|
"ticketId": ticket_id,
|
|
303
309
|
"disagreement": _text(finding.get("summary"), "Unresolved difference"),
|
|
304
310
|
"workersPosition": positions or [{"worker": "unknown", "itemId": "", "position": "unresolved"}],
|
|
@@ -311,5 +317,41 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
311
317
|
"differences": differences,
|
|
312
318
|
},
|
|
313
319
|
"promotedEvidence": promoted,
|
|
320
|
+
"criticUnverified": _critic_unverified(state),
|
|
314
321
|
}
|
|
322
|
+
critic = (state.get("config") or {}).get("critic")
|
|
323
|
+
if isinstance(critic, Mapping):
|
|
324
|
+
result["crossVerification"]["criticGaps"] = {
|
|
325
|
+
"proposed": critic.get("gapsProposed", 0),
|
|
326
|
+
"merged": critic.get("gapsMerged", 0),
|
|
327
|
+
"unverified": critic.get("gapsUnverified", 0),
|
|
328
|
+
"duplicate": critic.get("gapsDuplicate", 0),
|
|
329
|
+
}
|
|
315
330
|
return result
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _critic_unverified(state: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
334
|
+
"""미판정 gap 하나당 Missing Information 행 하나 — id 와 기본 ticket 은 조립이 채운다."""
|
|
335
|
+
ledger = (state.get("criticVerification") or {}).get("gaps") or []
|
|
336
|
+
tickets = {
|
|
337
|
+
gap.get("gapId"): (gap.get("ticketIds") or [None])[0]
|
|
338
|
+
for gap in ledger
|
|
339
|
+
if isinstance(gap, Mapping)
|
|
340
|
+
}
|
|
341
|
+
rows = []
|
|
342
|
+
for gap in state.get("unverifiedGaps") or []:
|
|
343
|
+
if not isinstance(gap, Mapping):
|
|
344
|
+
continue
|
|
345
|
+
gap_id = _text(gap.get("gapId"), "unknown")
|
|
346
|
+
rows.append({
|
|
347
|
+
"ticketId": tickets.get(gap_id),
|
|
348
|
+
"item": f"Coverage critic gap `{gap_id}` was never verified: "
|
|
349
|
+
f"{_text(gap.get('summary'), 'no summary')}",
|
|
350
|
+
"risk": (
|
|
351
|
+
f"Verification did not complete ({_text(gap.get('reason'), 'unknown reason')}), "
|
|
352
|
+
"so no analyser has judged this gap; it is neither a finding nor a rejection."
|
|
353
|
+
),
|
|
354
|
+
"owner": "Okstra lead",
|
|
355
|
+
"source": "critic-unverified",
|
|
356
|
+
})
|
|
357
|
+
return rows
|
|
@@ -14,6 +14,7 @@ from typing import Any, Mapping
|
|
|
14
14
|
from okstra_token_usage.blocks import accounting_workers
|
|
15
15
|
|
|
16
16
|
from .analysis_packet import reference_source_extracts
|
|
17
|
+
from .correction_round import correction_convergence_states
|
|
17
18
|
from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
|
|
18
19
|
from .phases.implementation_option_selection.authoring import (
|
|
19
20
|
requirement_instructions as selection_requirement_instructions,
|
|
@@ -251,6 +252,17 @@ class ReportSynthesisPacket:
|
|
|
251
252
|
]
|
|
252
253
|
if self.task_type == "implementation-planning":
|
|
253
254
|
return self._planning_requirement_instructions()
|
|
255
|
+
if self.task_type == "implementation" and any(
|
|
256
|
+
source.label.startswith(f"{CONVERGENCE_STATE_LABEL} (correction-r")
|
|
257
|
+
for source in self.sources
|
|
258
|
+
):
|
|
259
|
+
return [
|
|
260
|
+
"Each `Convergence state (correction-r<N>)` source is correction round N's "
|
|
261
|
+
"re-convergence of the verifiers' re-verification; the plain `Convergence "
|
|
262
|
+
"state` is the initial round's. Describe the stage's open findings from the "
|
|
263
|
+
"last round's re-convergence, not the initial one; report what each round "
|
|
264
|
+
"fixed through `implementation.correctionRounds`.",
|
|
265
|
+
]
|
|
254
266
|
if self.task_type != "implementation-option-selection":
|
|
255
267
|
return []
|
|
256
268
|
return selection_requirement_instructions(
|
|
@@ -371,6 +383,7 @@ _SOURCE_FIELDS = (
|
|
|
371
383
|
("Final report schema", "orchestrator", "instructionSet", "finalReportSchemaPath", True),
|
|
372
384
|
("Convergence state", "convergence", "run", "convergenceStatePath", True),
|
|
373
385
|
)
|
|
386
|
+
CONVERGENCE_STATE_LABEL = "Convergence state"
|
|
374
387
|
|
|
375
388
|
|
|
376
389
|
_READ_CHUNK_BYTES = 16_000
|
|
@@ -514,7 +527,7 @@ def _source_markdown(
|
|
|
514
527
|
brief = next((item.content.strip() for item in sources if item.label == "Task brief"), "")
|
|
515
528
|
if len(brief.encode("utf-8")) >= _SHARED_TEXT_MIN_BYTES:
|
|
516
529
|
content = content.replace(brief, 'Read the complete "Source: Task brief" above.')
|
|
517
|
-
if source.label
|
|
530
|
+
if source.label.startswith(CONVERGENCE_STATE_LABEL):
|
|
518
531
|
content = _convergence_view(content)
|
|
519
532
|
if content != source.content.rstrip("\n"):
|
|
520
533
|
view = [
|
|
@@ -815,6 +828,14 @@ def _source_specs(
|
|
|
815
828
|
path = str(Path(instruction_set) / "final-report-schema.json")
|
|
816
829
|
if path or required:
|
|
817
830
|
specs.append((label, owner, path, required))
|
|
831
|
+
if label == CONVERGENCE_STATE_LABEL and path:
|
|
832
|
+
specs.extend(
|
|
833
|
+
(f"{CONVERGENCE_STATE_LABEL} ({scope})", owner,
|
|
834
|
+
_relative(project_root, round_final), True)
|
|
835
|
+
for scope, round_final in correction_convergence_states(
|
|
836
|
+
_resolve(project_root, path)
|
|
837
|
+
)
|
|
838
|
+
)
|
|
818
839
|
if carried_source:
|
|
819
840
|
specs.append((CARRIED_STAGES_LABEL, "orchestrator", carried_source, True))
|
|
820
841
|
specs.extend(_user_response_specs(project_root, narrative_path))
|
|
@@ -3063,6 +3063,7 @@ def _write_instruction_set_sources(
|
|
|
3063
3063
|
)
|
|
3064
3064
|
if inp.clarification_response_path else ""
|
|
3065
3065
|
),
|
|
3066
|
+
verification_target_path=ctx.get("VERIFICATION_TARGET_RELATIVE_PATH", ""),
|
|
3066
3067
|
)
|
|
3067
3068
|
if inp.task_type == "technical-verification":
|
|
3068
3069
|
packet += materialize_technical_input_packet(
|
|
@@ -3370,10 +3371,11 @@ def _maybe_open_fix_cycle(inp: PrepareInputs, task_root: Path, existing: dict,
|
|
|
3370
3371
|
f"{', '.join(fix_cycles.FIX_CYCLE_ENTRY_PHASES)})에서만 허용됩니다: "
|
|
3371
3372
|
f"{inp.task_type}")
|
|
3372
3373
|
workflow = existing.get("workflow") or {}
|
|
3373
|
-
if
|
|
3374
|
+
if not fix_cycles.delivery_completed(workflow):
|
|
3374
3375
|
raise PrepareError(
|
|
3375
|
-
"--fix-cycle yes 는 release-handoff
|
|
3376
|
-
"(workflow.lastCompletedPhase
|
|
3376
|
+
"--fix-cycle yes 는 release-handoff 로 전달까지 끝난 task 에만 허용됩니다 "
|
|
3377
|
+
"(workflow.lastCompletedPhase 가 release-handoff 이고, skip·미전달 stage 로 "
|
|
3378
|
+
"다음 phase 가 release-handoff 로 남아 있지 않아야 함)")
|
|
3377
3379
|
brief_text = Path(inp.brief_path).read_text(encoding="utf-8")
|
|
3378
3380
|
fix_cycles.append_opened(
|
|
3379
3381
|
task_root, target_report=str(existing.get("latestReportRecordPath", "")),
|
|
@@ -3385,7 +3387,8 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
|
|
|
3385
3387
|
"""fix-cycles.jsonl 의 lazy-close → opened → run 기록. cycle id 반환.
|
|
3386
3388
|
|
|
3387
3389
|
- lazy-close: open cycle 에 release-handoff run 이 부착됐고 디스크 manifest 의
|
|
3388
|
-
lastCompletedPhase 가 release-handoff 면 닫는다.
|
|
3390
|
+
lastCompletedPhase 가 release-handoff 면 닫는다. 다음 phase 포인터가 다시
|
|
3391
|
+
release-handoff 를 가리키면(skip·미전달 stage) 닫지 않는다. manifest 는 prepare 가
|
|
3389
3392
|
이번 run 으로 재작성하기 *전* 값이어야 하므로 finalize 직전에 호출한다.
|
|
3390
3393
|
- opened: --fix-cycle yes 일 때만. 완료 task + entry phase 미충족이면
|
|
3391
3394
|
PrepareError.
|
|
@@ -3398,7 +3401,7 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
|
|
|
3398
3401
|
|
|
3399
3402
|
open_c = fix_cycles.open_cycle(fix_cycles.read_rows(task_root))
|
|
3400
3403
|
|
|
3401
|
-
if open_c and
|
|
3404
|
+
if open_c and fix_cycles.delivery_completed(workflow):
|
|
3402
3405
|
rows = fix_cycles.read_rows(task_root)
|
|
3403
3406
|
handoff_attached = any(
|
|
3404
3407
|
r.get("event") == "run" and r.get("cycle") == open_c["cycle"]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Git reconciliation shared by stage prepare flows."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import sys
|
|
@@ -31,3 +31,51 @@ def auto_reconcile_best_effort(inp: Any, plan_run_root: Path) -> None:
|
|
|
31
31
|
)
|
|
32
32
|
except Exception as exc:
|
|
33
33
|
print(f"git-reconcile skipped: {exc}", file=sys.stderr)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StaleStageRecordsError(RuntimeError):
|
|
37
|
+
"""A recorded stage SHA no longer matches git and only the user can say which commit counts."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_reconciled_stages(
|
|
41
|
+
inp: Any, plan_run_root: Path, *, stage: int | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Apply auto items, then refuse while a confirm item remains.
|
|
44
|
+
|
|
45
|
+
A prepare that restores, rebases or merges stage branches calls this first:
|
|
46
|
+
building on a recorded commit the branch no longer carries integrates work
|
|
47
|
+
nobody verified. ``stage`` narrows the check to that stage's done row.
|
|
48
|
+
"""
|
|
49
|
+
from .git_reconcile import _git, classify_task, guidance
|
|
50
|
+
|
|
51
|
+
auto_reconcile_best_effort(inp, plan_run_root)
|
|
52
|
+
# A project prepared without git (worktree status skipped-not-git) has no
|
|
53
|
+
# history the recorded SHAs could disagree with.
|
|
54
|
+
if _git(Path(inp.project_root), "rev-parse", "--git-dir").returncode != 0:
|
|
55
|
+
return
|
|
56
|
+
coordinates = dict(
|
|
57
|
+
project_id=inp.project_id,
|
|
58
|
+
task_group=inp.task_group,
|
|
59
|
+
task_id=inp.task_id,
|
|
60
|
+
work_category=inp.work_category,
|
|
61
|
+
)
|
|
62
|
+
pending = [
|
|
63
|
+
item
|
|
64
|
+
for item in classify_task(
|
|
65
|
+
project_root=Path(inp.project_root),
|
|
66
|
+
plan_run_root=plan_run_root,
|
|
67
|
+
**coordinates,
|
|
68
|
+
)
|
|
69
|
+
if item.classification == "confirm"
|
|
70
|
+
and (stage is None or item.stage == stage)
|
|
71
|
+
]
|
|
72
|
+
if not pending:
|
|
73
|
+
return
|
|
74
|
+
lines = "\n".join(
|
|
75
|
+
f" {f'stage {item.stage}' if item.kind == 'done' else 'anchor'}: "
|
|
76
|
+
f"{item.reason} (recorded {item.recorded})"
|
|
77
|
+
for item in pending
|
|
78
|
+
)
|
|
79
|
+
raise StaleStageRecordsError(
|
|
80
|
+
f"{lines}\n{guidance(plan_run_root=plan_run_root, **coordinates)}"
|
|
81
|
+
)
|
|
@@ -215,14 +215,14 @@ def _stage_plan_for_confirmation(
|
|
|
215
215
|
|
|
216
216
|
|
|
217
217
|
def _fix_cycle_confirm_required(state: WizardState) -> bool:
|
|
218
|
-
"""
|
|
219
|
-
cycle 이 없을 때만 묻는다."""
|
|
218
|
+
"""전달까지 끝난(release-handoff) task 에 entry phase 로 재진입하고, 아직
|
|
219
|
+
열린 fix cycle 이 없을 때만 묻는다."""
|
|
220
220
|
if state.task_type not in fix_cycles.FIX_CYCLE_ENTRY_PHASES:
|
|
221
221
|
return False
|
|
222
222
|
task_root = task_dir(Path(state.project_root),
|
|
223
223
|
state.task_group, state.task_id)
|
|
224
224
|
workflow = (read_task_manifest(task_root) or {}).get("workflow") or {}
|
|
225
|
-
if
|
|
225
|
+
if not fix_cycles.delivery_completed(workflow):
|
|
226
226
|
return False
|
|
227
227
|
return fix_cycles.open_cycle(fix_cycles.read_rows(task_root)) is None
|
|
228
228
|
|