okstra 0.186.3 → 0.186.5
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.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/project-structure-overview.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/report_assembly.py +68 -10
- package/runtime/python/okstra_ctl/report_finalize.py +85 -98
- package/runtime/validators/validate-run.py +149 -26
package/docs/architecture.md
CHANGED
|
@@ -865,7 +865,7 @@ The manifest-provided `lead-events-*.jsonl` file is the canonical record for str
|
|
|
865
865
|
|
|
866
866
|
`activityContractVersion: 1` is an interpretation version for new `implementation-planning` artifacts. A run without that field remains a historical run and does not require activity events or an `agentActivity[]` projection.
|
|
867
867
|
|
|
868
|
-
The shared `okstra report-finalize` entrypoint reads canonical activity before translation source checking. Under contract v3 its in-process `project-activity` step assembles the report, filtering events by run identity and validating activity ID order before `agentActivity[]` is published. A historical v2 manifest retains the in-place projection path. For a non-English report, the lead runs `token-usage`, `project-activity`, and `check-source` before translator dispatch, then resumes at `render-views` after the translation sidecar exists. Conformance compares the resulting `agentActivity[]` IDs, order, and core fields with the canonical events for every lead host.
|
|
868
|
+
The shared `okstra report-finalize` entrypoint reads canonical activity before translation source checking. Under contract v3 its in-process `project-activity` step assembles the report, filtering events by run identity and validating activity ID order before `agentActivity[]` is published. A schema-invalid composed record is still published so `validate-run` can scan it; the step itself fails. A historical v2 manifest retains the in-place projection path. A failed Phase 7 step does not skip later checks: `validate-run` still runs, and only `teardown-stages` is skipped. For a non-English report, the lead runs `token-usage`, `project-activity`, and `check-source` before translator dispatch, then resumes at `render-views` after the translation sidecar exists. Conformance compares the resulting `agentActivity[]` IDs, order, and core fields with the canonical events for every lead host.
|
|
869
869
|
|
|
870
870
|
Approval blockers use `open`, `answered`, `resolved`, and `obsolete`. `open` blocks until the user judges. `answered` with `accept-risk` / `select` / `answer` does not block approval or the next phase; the DISAGREE votes stay on the plan item as evidence. A response sidecar with a proceeding disposition unblocks the same way. `request-revision` and `reject` still withhold the next phase.
|
|
871
871
|
|
package/docs/cli.md
CHANGED
|
@@ -862,7 +862,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
|
|
|
862
862
|
| `okstra approval-decision <open\|resolve\|carry> --ledger <approval-decisions.json> …` | Write the lead-owned clarification and approval ledger. `open` validates classification-specific dispositions and complete option fields, `resolve` requires real `A-NNN` check references, and `carry` keeps prior resolved decisions outside the active clarification list. |
|
|
863
863
|
| `okstra design-snapshot --narrative <report-narrative.md> --output <design-preparation.json>` | Detect implementation-planning design surfaces and write the detector-owned snapshot consumed by final report assembly. |
|
|
864
864
|
| `okstra plan-verify --narrative <report-narrative.md> --state <plan-body-verification.json>` | Recompute the plan-body gate from the convergence-owned state before `data.json` publication. `--report <historical-data.json>` remains the v2 reader. |
|
|
865
|
-
| `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run Phase 7 in the manifest's contract order. Contract v3 collects usage into team state, assembles all single-owner inputs into `data.json` once, then checks, renders, spawns follow-ups, validates, and tears down eligible stage worktrees. Contract v2 retains its historical in-place projection sequence as a read-only compatibility path.
|
|
865
|
+
| `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run Phase 7 in the manifest's contract order. Contract v3 collects usage into team state, assembles all single-owner inputs into `data.json` once, then checks, renders, spawns follow-ups, validates, and tears down eligible stage worktrees. Contract v2 retains its historical in-place projection sequence as a read-only compatibility path. A failed step still runs every later check through `validate-run`; only `teardown-stages` is skipped so a failed run does not reclaim worktrees. Reports each step and prints the ordered `--only` recovery tail from the earliest failure. This is the shared path for every lead adapter. |
|
|
866
866
|
| `okstra render-views <final-report.data.json\|final-report.md>` | The Phase 7 `render-views` step, runnable on its own. Schema v2 data is rendered directly, and schema v3 data uses the same always-generated, task-specific human HTML path. The full reading copy uses `templates/reports/final-report-v2.template.md` and is rendered on demand with `okstra render-final-report`. Passing the Markdown sibling locates the same data.json. Schema v1 and quick reports keep the legacy conditional renderer. The Node wrapper calls `scripts/okstra-render-report-views.py`; `validators/validate-report-views.py` verifies source/schema/template digests, required human fields, form controls, external assets, diagram/table ID parity, and Response ID parity. |
|
|
867
867
|
| `okstra design-prep <list\|show\|write>` | Review AI-prepared implementation design requests, inspect their effective confirmed response, or append a confirmed user/wizard response without editing the planning report |
|
|
868
868
|
| `okstra wizard <init\|step\|render-args\|confirmation\|outcome> --state-file <path>` | Interactive input state machine for okstra-run, implemented by `okstra_ctl.wizard`. Seed a state file with `init`, then repeatedly call `step --answer <val>` to receive the next `Prompt` JSON. `--answer` is **required**; use `--no-submit` to peek at the next prompt without submitting a response. A `pick` with more choices than the host picker can display keeps `kind: "pick"` but adds `presentation: "numbered-text"`; render every option as a numbered Markdown list and submit the user's 1-based number, exact value, or exact label. Invalid, out-of-range, and ambiguous answers re-prompt without dropping choices. `render-args` returns the final `render-bundle` argument map, and `confirmation` returns the user echo block. On a completed wizard, `outcome` returns `renderArgs`, `persistActions`, and `confirmationText` together; project/global release-handoff PR-template persistence appears as `persistActions[].command == "config.set"`. For an `implementation` task type, `stage_pick` follows `approved_plan_pick` and selects the stage before `executor_pick`. The brief step appears only for entry task types—requirements-discovery, error-analysis, improvement-discovery, project-analysis, feature-analysis, and change-impact-analysis. Analysis inputs use `feature_evidence_pick` / `feature_evidence`, `project_evidence_pick` / `project_evidence`, and `analysis_target_pick` / `analysis_target`; a revision-requested report prioritizes its same-task, same-type rerun. Downstream lifecycle phases automatically carry the manifest brief, with a three-option `brief_carry` fallback when none is registered; `release-handoff` has no brief and enters multi-select `handoff_stage_pick` for eligible stage groups or the whole task |
|
|
@@ -343,7 +343,7 @@ Important modules:
|
|
|
343
343
|
| `plan_derivations.py` | the supersession sweep `_common-contract.md` requires an author to do by hand — extracts the symbols, paths, and ids an answered clarification names and reports every plan string that mentions one. Advisory: it locates candidates and never judges which are now false |
|
|
344
344
|
| `scope_provenance.py` | single source of truth for the scope-provenance grammar every phase-emitted requirement must declare, shared by `validators/validate-run.py` and `validators/validate_fanout.py` so the planning report and fan-out packets cannot drift |
|
|
345
345
|
| `worker_artifact_paths.py` | canonical worker artifact path derivation (e.g. `audit_sidecar_rel` inserts `-audit-` after the first `-worker-` token), so dispatch and validation agree on non-canonical-path rejection |
|
|
346
|
-
| `report_finalize.py` | Phase 7 post-report sequence **SSOT** — runs `check-source` → `token-usage` → `render-views` → `spawn-followups` → `validate-run` in that load-bearing order
|
|
346
|
+
| `report_finalize.py` | Phase 7 post-report sequence **SSOT** — runs `check-source` → `token-usage` → `render-views` → `spawn-followups` → `validate-run` in that load-bearing order. A non-zero exit still runs every later check through `validate-run` and names the earliest failure; `teardown-stages` is skipped when any earlier step failed. Both lead paths converge here: the Codex adapter calls it in-process (`codex_dispatch`), a Claude-led run reaches it through `okstra report-finalize`. Neither reimplements the sequence |
|
|
347
347
|
| `wrapper_status.py` | worker wrapper status sidecar reader — the host-side reader of the sidecar `worker_runner.py` writes. `is_terminal` is the one question it answers for the dispatch record and the pane reclaim: does `stage` read `exited` |
|
|
348
348
|
| `worker_runner.py` | runs one worker CLI and records what happened — shared by every provider entrypoint. Owns the `selectors` pump over the child's streams, the stream-arrival idle watchdog (`killpg` on breach), the run-wide progress cap on the log copy, and the status sidecar's whole life. A run that dies after launch still closes its sidecar, so `worker_liveness` never reads a dead worker as running |
|
|
349
349
|
| `session_transcript.py` | worker session transcript — one line per event (time, speaker, body) with a run-wide progress-line cap (`LOG_LINE_CAP`, elision notice) so a single-file dispatch's tool echo cannot dominate the project's `.okstra/` bytes; the fixed shape lets a later lead write share the same file |
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -131,10 +131,65 @@ def _clarifications(
|
|
|
131
131
|
if not isinstance(row, Mapping):
|
|
132
132
|
_fail("lead", path, f"activeClarifications[{index}]", "must be an object")
|
|
133
133
|
activity_by_id = {row.get("activityId"): row for row in activities}
|
|
134
|
-
|
|
134
|
+
rows = [
|
|
135
135
|
_clarification_row(row, activity_by_id, path)
|
|
136
136
|
for row in active
|
|
137
137
|
]
|
|
138
|
+
rows.extend(_carried_clarification_rows(ledger, {row.get("id") for row in rows}, path))
|
|
139
|
+
return rows
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _carried_clarification_rows(
|
|
143
|
+
ledger: Mapping[str, Any], seen_ids: set[object], path: Path,
|
|
144
|
+
) -> list[dict[str, Any]]:
|
|
145
|
+
"""이월 결정은 active 질문이 아니다. 이번 런 활동 원장을 요구하지 않는다."""
|
|
146
|
+
carried = ledger.get("carriedDecisions")
|
|
147
|
+
if not isinstance(carried, list):
|
|
148
|
+
return []
|
|
149
|
+
rows: list[dict[str, Any]] = []
|
|
150
|
+
seen = set(seen_ids)
|
|
151
|
+
for index, entry in enumerate(carried):
|
|
152
|
+
if not isinstance(entry, Mapping):
|
|
153
|
+
_fail("lead", path, f"carriedDecisions[{index}]", "must be an object")
|
|
154
|
+
decision = entry.get("decision")
|
|
155
|
+
if not isinstance(decision, Mapping):
|
|
156
|
+
_fail(
|
|
157
|
+
"lead", path, f"carriedDecisions[{index}].decision",
|
|
158
|
+
"must be an object",
|
|
159
|
+
)
|
|
160
|
+
cid = decision.get("id")
|
|
161
|
+
if cid in seen:
|
|
162
|
+
continue
|
|
163
|
+
rows.append(_carried_clarification_row(decision))
|
|
164
|
+
seen.add(cid)
|
|
165
|
+
return rows
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _carried_clarification_row(source: Mapping[str, Any]) -> dict[str, Any]:
|
|
169
|
+
row = {
|
|
170
|
+
key: source[key]
|
|
171
|
+
for key in (
|
|
172
|
+
"id", "ticketId", "kind", "statement", "expectedForm", "blocks",
|
|
173
|
+
"origin", "userConfirmation", "options",
|
|
174
|
+
)
|
|
175
|
+
if key in source
|
|
176
|
+
}
|
|
177
|
+
if "approval" in source:
|
|
178
|
+
row["approvalContext"] = source["approval"]
|
|
179
|
+
resolution = source.get("resolutionInput")
|
|
180
|
+
if not isinstance(resolution, Mapping):
|
|
181
|
+
row["status"] = "resolved"
|
|
182
|
+
return row
|
|
183
|
+
row.update(
|
|
184
|
+
status="resolved",
|
|
185
|
+
userInput=resolution.get("userText", ""),
|
|
186
|
+
resolution={
|
|
187
|
+
"disposition": resolution.get("disposition"),
|
|
188
|
+
"userText": resolution.get("userText"),
|
|
189
|
+
"checkRefs": list(resolution.get("checkRefs") or []),
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
return row
|
|
138
193
|
|
|
139
194
|
|
|
140
195
|
def _project_relative(project_root: Path, path: Path) -> str:
|
|
@@ -464,7 +519,12 @@ def assemble_report(
|
|
|
464
519
|
project_root: Path, manifest_path: Path, *,
|
|
465
520
|
replace: Callable[[str, str], None] = os.replace,
|
|
466
521
|
) -> dict[str, Any]:
|
|
467
|
-
"""
|
|
522
|
+
"""계약 3.0 정본을 조립해 게시한다.
|
|
523
|
+
|
|
524
|
+
입력이 없어 조립 자체가 안 되면 기존 파일을 건드리지 않는다. 조립은
|
|
525
|
+
됐는데 스키마만 깨진 기록은 게시한 뒤 거부한다. 게시를 건너뛰면
|
|
526
|
+
`validate-run` 이 이번 런 내용을 보지 못한다.
|
|
527
|
+
"""
|
|
468
528
|
manifest_row = ReportInputPath("run-manifest", "orchestrator", manifest_path)
|
|
469
529
|
manifest = _read_json(manifest_row)
|
|
470
530
|
if not uses_report_contract_v3(manifest):
|
|
@@ -490,9 +550,13 @@ def assemble_report(
|
|
|
490
550
|
raise ReportAssemblyError(tuple(input_issues))
|
|
491
551
|
data = _compose(project_root, manifest_path, manifest, inputs, schema)
|
|
492
552
|
errors = validate(data, schema)
|
|
553
|
+
value = manifest.get("expectedReportRecordPath")
|
|
554
|
+
if not isinstance(value, str) or not value:
|
|
555
|
+
_fail("orchestrator", manifest_path, "expectedReportRecordPath", "required")
|
|
556
|
+
target = Path(value)
|
|
557
|
+
target = target if target.is_absolute() else project_root / target
|
|
558
|
+
_publish(target, data, replace)
|
|
493
559
|
if errors:
|
|
494
|
-
target = Path(str(manifest.get("expectedReportRecordPath") or "data.json"))
|
|
495
|
-
target = target if target.is_absolute() else project_root / target
|
|
496
560
|
raise ReportAssemblyError(
|
|
497
561
|
tuple(
|
|
498
562
|
AssemblyIssue(
|
|
@@ -504,10 +568,4 @@ def assemble_report(
|
|
|
504
568
|
for error in errors
|
|
505
569
|
)
|
|
506
570
|
)
|
|
507
|
-
value = manifest.get("expectedReportRecordPath")
|
|
508
|
-
if not isinstance(value, str) or not value:
|
|
509
|
-
_fail("orchestrator", manifest_path, "expectedReportRecordPath", "required")
|
|
510
|
-
target = Path(value)
|
|
511
|
-
target = target if target.is_absolute() else project_root / target
|
|
512
|
-
_publish(target, data, replace)
|
|
513
571
|
return data
|
|
@@ -7,17 +7,21 @@ read-only compatibility. The order is load-bearing: rendering before assembly
|
|
|
7
7
|
or usage collection publishes stale derived views, and validating before
|
|
8
8
|
rendering trips the report-views contract.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`
|
|
10
|
+
A non-zero exit no longer stops the sequence before `validate-run`. A schema
|
|
11
|
+
cap used to hide the contract scan: assembly refused, later steps never ran,
|
|
12
|
+
and the lead only saw those failures after a second finalize. Each step still
|
|
13
|
+
records its own exit; `ok` stays False when any step failed. `teardown-stages`
|
|
14
|
+
is the exception — it reclaims worktrees, so a failed prefix skips it.
|
|
15
|
+
|
|
16
|
+
`token-usage` reads the lead session log (state outside this run). A refusal
|
|
17
|
+
there used to delete every later artifact; it no longer does, because the
|
|
18
|
+
sequence continues. `validators/validate-run.py` re-collects when the recorded
|
|
19
|
+
usage is all zeros against an `unavailable` session source (`_needs_token_autofix`)
|
|
20
|
+
and refuses the run rather than ship zeroed counts (`accuracy-failed`). A
|
|
21
|
+
legacy v1 report is caught earlier still, by its unsubstituted `{{...}}`
|
|
22
|
+
placeholders, which a v2 report never carries because its numeric cells are
|
|
23
|
+
`null` until this step fills them. Substituting the tokens on a later retry
|
|
24
|
+
then leaves the already-rendered html stale for `validators/validate-report-views.py`.
|
|
21
25
|
|
|
22
26
|
The translation sidecar is NOT one of these steps. `render-views` overlays it,
|
|
23
27
|
so a non-English run dispatches the translator before this sequence starts —
|
|
@@ -447,26 +451,14 @@ def run_finalize(
|
|
|
447
451
|
before_step: Callable[[str], None] | None = None,
|
|
448
452
|
only: Sequence[str] | None = None,
|
|
449
453
|
) -> dict[str, Any]:
|
|
450
|
-
"""
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
fails, and yields to any later failure, which is the step that actually
|
|
457
|
-
blocked the run.
|
|
458
|
-
|
|
459
|
-
``before_step`` fires immediately before each step is spawned, letting an
|
|
460
|
-
adapter settle state the step will read (the Codex adapter marks the report
|
|
461
|
-
writer `completed` before `validate-run` inspects team-state).
|
|
462
|
-
|
|
463
|
-
``only`` restricts the run to the named steps, preserving contractual
|
|
464
|
-
order. The last step (`validate-run`) is the one that usually fails, and
|
|
465
|
-
re-running the whole sequence to retry it repeats every preceding idempotent
|
|
466
|
-
step at full token and wall-clock cost.
|
|
454
|
+
"""계약 순서로 Phase 7 을 돌리고, 실패해도 `validate-run` 까지 모은다.
|
|
455
|
+
|
|
456
|
+
`before_step` 은 각 단계 프로세스 직전에 호출된다. Codex 어댑터가
|
|
457
|
+
`validate-run` 앞에 작성기 상태를 `completed` 로 표시하는 자리이다.
|
|
458
|
+
|
|
459
|
+
`only` 는 고른 단계만 계약 순서로 남긴다.
|
|
467
460
|
"""
|
|
468
461
|
steps: list[dict[str, Any]] = []
|
|
469
|
-
deferred = ""
|
|
470
462
|
try:
|
|
471
463
|
if ctx.report_contract_version != "3.0":
|
|
472
464
|
write_execution_roles(ctx)
|
|
@@ -476,7 +468,9 @@ def run_finalize(
|
|
|
476
468
|
|
|
477
469
|
if only:
|
|
478
470
|
selected = set(only)
|
|
479
|
-
contract_order =
|
|
471
|
+
contract_order = (
|
|
472
|
+
V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
|
|
473
|
+
)
|
|
480
474
|
unknown = sorted(selected - set(contract_order))
|
|
481
475
|
if unknown:
|
|
482
476
|
return {
|
|
@@ -486,75 +480,70 @@ def run_finalize(
|
|
|
486
480
|
}
|
|
487
481
|
commands = [(name, cmd) for name, cmd in commands if name in selected]
|
|
488
482
|
|
|
483
|
+
first_failure = ""
|
|
489
484
|
for name, command in commands:
|
|
485
|
+
# 실패한 시퀀스가 worktree 를 거두면 재작업 대상이 사라진다.
|
|
486
|
+
if name == STEP_TEARDOWN_STAGES and first_failure:
|
|
487
|
+
continue
|
|
490
488
|
if before_step is not None:
|
|
491
489
|
before_step(name)
|
|
492
|
-
|
|
493
|
-
try:
|
|
494
|
-
if ctx.report_contract_version == "3.0":
|
|
495
|
-
assembled = assemble_report(ctx.project_root, ctx.manifest_path)
|
|
496
|
-
count = len(assembled.get("agentActivity") or [])
|
|
497
|
-
else:
|
|
498
|
-
rows = project_agent_activity(
|
|
499
|
-
ctx.project_root,
|
|
500
|
-
ctx.manifest_path,
|
|
501
|
-
ctx.data_path,
|
|
502
|
-
)
|
|
503
|
-
count = len(rows)
|
|
504
|
-
except (ActivityProjectionError, ReportAssemblyError) as exc:
|
|
505
|
-
result = subprocess.CompletedProcess(
|
|
506
|
-
command,
|
|
507
|
-
1,
|
|
508
|
-
"",
|
|
509
|
-
str(exc),
|
|
510
|
-
)
|
|
511
|
-
else:
|
|
512
|
-
result = subprocess.CompletedProcess(
|
|
513
|
-
command,
|
|
514
|
-
0,
|
|
515
|
-
json.dumps({"count": count}),
|
|
516
|
-
"",
|
|
517
|
-
)
|
|
518
|
-
else:
|
|
519
|
-
result = None
|
|
520
|
-
if name == STEP_TEARDOWN_STAGES:
|
|
521
|
-
result = _teardown_stage_worktrees(ctx, command)
|
|
522
|
-
if name == STEP_VALIDATE_RUN:
|
|
523
|
-
try:
|
|
524
|
-
_link_lead_result_for_validation(ctx)
|
|
525
|
-
except (DispatchError, OSError, json.JSONDecodeError) as exc:
|
|
526
|
-
return {
|
|
527
|
-
"ok": False,
|
|
528
|
-
"reason": f"lead result linkage failed: {exc}",
|
|
529
|
-
"steps": steps,
|
|
530
|
-
}
|
|
531
|
-
if result is None:
|
|
532
|
-
result = subprocess.run(
|
|
533
|
-
command,
|
|
534
|
-
cwd=ctx.project_root,
|
|
535
|
-
text=True,
|
|
536
|
-
capture_output=True,
|
|
537
|
-
)
|
|
490
|
+
result = _run_finalize_step(ctx, name, command)
|
|
538
491
|
steps.append(step_payload(name, command, result))
|
|
539
|
-
if result.returncode != 0:
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
# run's output with it: html never rendered, follow-ups never
|
|
544
|
-
# spawned, and `validate-run` then blocked on `report-views:
|
|
545
|
-
# missing html artifact`. So this step alone defers instead of
|
|
546
|
-
# stopping the sequence. It hides nothing — the non-zero exit stays
|
|
547
|
-
# in `steps`, `ok` stays False, and the closing `validate-run`
|
|
548
|
-
# still refuses the run.
|
|
549
|
-
if name == STEP_TOKEN_USAGE:
|
|
550
|
-
deferred = failure
|
|
551
|
-
continue
|
|
552
|
-
return {"ok": False, "reason": failure, "steps": steps}
|
|
553
|
-
if deferred:
|
|
554
|
-
return {"ok": False, "reason": deferred, "steps": steps}
|
|
492
|
+
if result.returncode != 0 and not first_failure:
|
|
493
|
+
first_failure = f"{name} failed with exit code {result.returncode}"
|
|
494
|
+
if first_failure:
|
|
495
|
+
return {"ok": False, "reason": first_failure, "steps": steps}
|
|
555
496
|
return {"ok": True, "reason": "", "steps": steps}
|
|
556
497
|
|
|
557
498
|
|
|
499
|
+
def _run_finalize_step(
|
|
500
|
+
ctx: FinalizeContext,
|
|
501
|
+
name: str,
|
|
502
|
+
command: Sequence[str],
|
|
503
|
+
) -> subprocess.CompletedProcess[str]:
|
|
504
|
+
"""한 Phase 7 단계를 실행하고 그 단계의 종료 코드만 돌려준다."""
|
|
505
|
+
if name == STEP_PROJECT_ACTIVITY:
|
|
506
|
+
return _run_project_activity(ctx, command)
|
|
507
|
+
if name == STEP_TEARDOWN_STAGES:
|
|
508
|
+
return _teardown_stage_worktrees(ctx, command)
|
|
509
|
+
if name == STEP_VALIDATE_RUN:
|
|
510
|
+
try:
|
|
511
|
+
_link_lead_result_for_validation(ctx)
|
|
512
|
+
except (DispatchError, OSError, json.JSONDecodeError) as exc:
|
|
513
|
+
return subprocess.CompletedProcess(
|
|
514
|
+
command, 1, "", f"lead result linkage failed: {exc}"
|
|
515
|
+
)
|
|
516
|
+
return subprocess.run(
|
|
517
|
+
command,
|
|
518
|
+
cwd=ctx.project_root,
|
|
519
|
+
text=True,
|
|
520
|
+
capture_output=True,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _run_project_activity(
|
|
525
|
+
ctx: FinalizeContext,
|
|
526
|
+
command: Sequence[str],
|
|
527
|
+
) -> subprocess.CompletedProcess[str]:
|
|
528
|
+
"""계약 3.0 은 조립, 그 외는 활동 투영만 한다."""
|
|
529
|
+
try:
|
|
530
|
+
if ctx.report_contract_version == "3.0":
|
|
531
|
+
assembled = assemble_report(ctx.project_root, ctx.manifest_path)
|
|
532
|
+
count = len(assembled.get("agentActivity") or [])
|
|
533
|
+
else:
|
|
534
|
+
rows = project_agent_activity(
|
|
535
|
+
ctx.project_root,
|
|
536
|
+
ctx.manifest_path,
|
|
537
|
+
ctx.data_path,
|
|
538
|
+
)
|
|
539
|
+
count = len(rows)
|
|
540
|
+
except (ActivityProjectionError, ReportAssemblyError) as exc:
|
|
541
|
+
return subprocess.CompletedProcess(command, 1, "", str(exc))
|
|
542
|
+
return subprocess.CompletedProcess(
|
|
543
|
+
command, 0, json.dumps({"count": count}), ""
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
558
547
|
def write_execution_roles(ctx: FinalizeContext) -> None:
|
|
559
548
|
"""Write the exact manifest role set onto the final-report data.json."""
|
|
560
549
|
if not ctx.data_path.is_file() or not ctx.manifest_path.is_file():
|
|
@@ -685,13 +674,11 @@ def _recovery_step_names(
|
|
|
685
674
|
"""The steps a retry has to re-run: every step from the earliest failure on.
|
|
686
675
|
|
|
687
676
|
Naming only the failed steps would prescribe half a recovery. `token-usage`
|
|
688
|
-
|
|
689
|
-
|
|
677
|
+
can fail while `render-views` still writes html from unsubstituted data;
|
|
678
|
+
substituting the tokens on a retry leaves that view stale
|
|
690
679
|
(`validators/validate-report-views.py` checks `source-sha256` against the
|
|
691
|
-
md body).
|
|
692
|
-
|
|
693
|
-
the earliest failure redoes both while still skipping the prefix that
|
|
694
|
-
succeeded — the saving `--only` exists for.
|
|
680
|
+
md body). Resuming from the earliest failure redoes the tail while skipping
|
|
681
|
+
the prefix that succeeded — the saving `--only` exists for.
|
|
695
682
|
"""
|
|
696
683
|
failed = {
|
|
697
684
|
string_value(step.get("name"))
|
|
@@ -3492,8 +3492,8 @@ def validate_final_report_data(
|
|
|
3492
3492
|
errors = schema_validate(data, schema)
|
|
3493
3493
|
for err in errors:
|
|
3494
3494
|
failures.append(f"final-report data.json: {err}")
|
|
3495
|
-
|
|
3496
|
-
|
|
3495
|
+
# 스키마 실패가 계약 스캔을 가리지 않는다. 빈 decisionRefs 가
|
|
3496
|
+
# minItems 에서 막히면 종료상태 표 누락이 안 보였다.
|
|
3497
3497
|
|
|
3498
3498
|
manifest = run_manifest or {}
|
|
3499
3499
|
if data.get("executionIdentityVersion") == 2 or data.get("executionRoles"):
|
|
@@ -3595,8 +3595,11 @@ def validate_final_report_data(
|
|
|
3595
3595
|
data, _project_root_from_report(report_path)
|
|
3596
3596
|
):
|
|
3597
3597
|
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
3598
|
+
carried = _carried_decision_map(
|
|
3599
|
+
manifest, project_root=project_root, report_path=report_path
|
|
3600
|
+
)
|
|
3598
3601
|
_validate_supersession_ledger(data, failures)
|
|
3599
|
-
_validate_clarification_evidence_note(data, failures)
|
|
3602
|
+
_validate_clarification_evidence_note(data, failures, carried=carried)
|
|
3600
3603
|
_validate_approval_clarification_backtrace(data, failures)
|
|
3601
3604
|
_validate_rerun_guidance(data, failures)
|
|
3602
3605
|
_validate_approval_guidance(data, failures)
|
|
@@ -3606,7 +3609,11 @@ def validate_final_report_data(
|
|
|
3606
3609
|
failures,
|
|
3607
3610
|
)
|
|
3608
3611
|
if not selected_direction_contract:
|
|
3609
|
-
_validate_requirement_deviations(
|
|
3612
|
+
_validate_requirement_deviations(
|
|
3613
|
+
data,
|
|
3614
|
+
failures,
|
|
3615
|
+
carried=carried,
|
|
3616
|
+
)
|
|
3610
3617
|
_validate_requirement_coverage_covered_by(data, failures)
|
|
3611
3618
|
warnings = _validate_design_prep_contract(
|
|
3612
3619
|
data,
|
|
@@ -5778,6 +5785,14 @@ def _validate_self_fix_rewrite_scope(data: dict, failures: list[str]) -> None:
|
|
|
5778
5785
|
)
|
|
5779
5786
|
|
|
5780
5787
|
|
|
5788
|
+
def _analyser_key(worker: str) -> str:
|
|
5789
|
+
"""`codex` 와 `codex-worker` 는 같은 분석기다."""
|
|
5790
|
+
name = worker.strip()
|
|
5791
|
+
if name.endswith("-worker"):
|
|
5792
|
+
return name[: -len("-worker")]
|
|
5793
|
+
return name
|
|
5794
|
+
|
|
5795
|
+
|
|
5781
5796
|
def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
|
|
5782
5797
|
"""The gate's own arithmetic base, checked against the votes it ran on.
|
|
5783
5798
|
|
|
@@ -5809,12 +5824,13 @@ def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
|
|
|
5809
5824
|
return
|
|
5810
5825
|
|
|
5811
5826
|
observed = {
|
|
5812
|
-
str(v.get("worker"))
|
|
5827
|
+
_analyser_key(str(v.get("worker")))
|
|
5813
5828
|
for item in (pbv.get("planItems") or [])
|
|
5814
5829
|
if isinstance(item, dict)
|
|
5815
5830
|
for v in (item.get("verdicts") or [])
|
|
5816
5831
|
if isinstance(v, dict) and str(v.get("verdict") or "") != "verification-error"
|
|
5817
5832
|
}
|
|
5833
|
+
observed.discard("")
|
|
5818
5834
|
if observed and voting != len(observed):
|
|
5819
5835
|
failures.append(
|
|
5820
5836
|
"final-report data.json: planBodyVerification.participatingAnalysers "
|
|
@@ -5917,7 +5933,9 @@ _EVIDENCE_NONE_RE = re.compile(r"^none\s*[—-]\s*\S")
|
|
|
5917
5933
|
_EVIDENCE_PATH_LINE_RE = re.compile(r"\S+\.\w+:\d+")
|
|
5918
5934
|
|
|
5919
5935
|
|
|
5920
|
-
def _validate_clarification_evidence_note(
|
|
5936
|
+
def _validate_clarification_evidence_note(
|
|
5937
|
+
data: dict, failures: list[str], *, carried: dict | None = None,
|
|
5938
|
+
) -> None:
|
|
5921
5939
|
"""Every clarification row must show its codebase-first work.
|
|
5922
5940
|
|
|
5923
5941
|
The profile requires any ambiguity answerable by `Read` / `Grep` to be
|
|
@@ -5930,10 +5948,13 @@ def _validate_clarification_evidence_note(data: dict, failures: list[str]) -> No
|
|
|
5930
5948
|
"""
|
|
5931
5949
|
if (data.get("header") or {}).get("taskType") != "implementation-planning":
|
|
5932
5950
|
return
|
|
5951
|
+
carried_ids = set((carried or {}).keys())
|
|
5933
5952
|
for row in data.get("clarificationItems") or []:
|
|
5934
5953
|
if not isinstance(row, dict):
|
|
5935
5954
|
continue
|
|
5936
5955
|
row_id = str(row.get("id") or "<unknown>")
|
|
5956
|
+
if row_id in carried_ids:
|
|
5957
|
+
continue
|
|
5937
5958
|
statement = str(row.get("statement") or "")
|
|
5938
5959
|
match = _EVIDENCE_NOTE_RE.search(statement)
|
|
5939
5960
|
if not match:
|
|
@@ -8628,14 +8649,83 @@ _DEVIATION_DECISION_REF_RE = re.compile(r"^(C-\d{3,}|D-\d{4,})$")
|
|
|
8628
8649
|
_DEVIATION_BLOCKED_DISPOSITION_RE = re.compile(r"^blocked (C-\d{3,})$")
|
|
8629
8650
|
|
|
8630
8651
|
|
|
8652
|
+
def _carried_decision_map(
|
|
8653
|
+
run_manifest: Mapping[str, Any] | None,
|
|
8654
|
+
*,
|
|
8655
|
+
project_root: Path | None,
|
|
8656
|
+
report_path: Path,
|
|
8657
|
+
) -> dict[str, dict]:
|
|
8658
|
+
"""이전 런에서 carry 한 결정. active `clarificationItems` 에 다시 올리지 않는다."""
|
|
8659
|
+
raw = (run_manifest or {}).get("approvalDecisionsPath")
|
|
8660
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
8661
|
+
return {}
|
|
8662
|
+
path = Path(raw.strip())
|
|
8663
|
+
if not path.is_absolute():
|
|
8664
|
+
root = project_root or _project_root_from_report(report_path)
|
|
8665
|
+
path = root / path
|
|
8666
|
+
if not path.is_file():
|
|
8667
|
+
return {}
|
|
8668
|
+
try:
|
|
8669
|
+
ledger = json.loads(path.read_text(encoding="utf-8"))
|
|
8670
|
+
except (OSError, json.JSONDecodeError):
|
|
8671
|
+
return {}
|
|
8672
|
+
if not isinstance(ledger, dict):
|
|
8673
|
+
return {}
|
|
8674
|
+
carried: dict[str, dict] = {}
|
|
8675
|
+
for row in ledger.get("carriedDecisions") or []:
|
|
8676
|
+
if not isinstance(row, dict):
|
|
8677
|
+
continue
|
|
8678
|
+
decision = row.get("decision")
|
|
8679
|
+
if not isinstance(decision, dict):
|
|
8680
|
+
continue
|
|
8681
|
+
cid = decision.get("id")
|
|
8682
|
+
if isinstance(cid, str) and cid:
|
|
8683
|
+
carried[cid] = decision
|
|
8684
|
+
return carried
|
|
8685
|
+
|
|
8686
|
+
|
|
8687
|
+
def _deviation_target(
|
|
8688
|
+
ref: str,
|
|
8689
|
+
clarifications: dict,
|
|
8690
|
+
decisions: dict,
|
|
8691
|
+
carried: dict,
|
|
8692
|
+
) -> dict | None:
|
|
8693
|
+
if ref.startswith("C-"):
|
|
8694
|
+
row = clarifications.get(ref) or carried.get(ref)
|
|
8695
|
+
return row if isinstance(row, dict) else None
|
|
8696
|
+
row = decisions.get(ref)
|
|
8697
|
+
return row if isinstance(row, dict) else None
|
|
8698
|
+
|
|
8699
|
+
|
|
8700
|
+
def _deviation_is_user_confirmed(
|
|
8701
|
+
ref: str, clarifications: dict, carried: dict,
|
|
8702
|
+
) -> bool:
|
|
8703
|
+
row = clarifications.get(ref)
|
|
8704
|
+
if (
|
|
8705
|
+
isinstance(row, dict)
|
|
8706
|
+
and row.get("status") in {"answered", "resolved"}
|
|
8707
|
+
and str(row.get("userInput") or "").strip()
|
|
8708
|
+
):
|
|
8709
|
+
return True
|
|
8710
|
+
carried_row = carried.get(ref)
|
|
8711
|
+
if not isinstance(carried_row, dict):
|
|
8712
|
+
return False
|
|
8713
|
+
resolution = carried_row.get("resolutionInput")
|
|
8714
|
+
if isinstance(resolution, dict) and str(resolution.get("userText") or "").strip():
|
|
8715
|
+
return True
|
|
8716
|
+
return bool(str(carried_row.get("userConfirmation") or "").strip())
|
|
8717
|
+
|
|
8718
|
+
|
|
8631
8719
|
def _resolved_deviation_refs(
|
|
8632
8720
|
row_id: str,
|
|
8633
8721
|
refs: object,
|
|
8634
8722
|
clarifications: dict,
|
|
8635
8723
|
decisions: dict,
|
|
8636
8724
|
failures: list[str],
|
|
8725
|
+
carried: dict | None = None,
|
|
8637
8726
|
) -> list[str]:
|
|
8638
8727
|
valid_refs: list[str] = []
|
|
8728
|
+
carried_rows = carried or {}
|
|
8639
8729
|
for ref in refs if isinstance(refs, list) else []:
|
|
8640
8730
|
if not isinstance(ref, str) or not _DEVIATION_DECISION_REF_RE.fullmatch(ref):
|
|
8641
8731
|
failures.append(
|
|
@@ -8643,12 +8733,7 @@ def _resolved_deviation_refs(
|
|
|
8643
8733
|
f"unsupported decisionRef `{ref}`; expected C-NNN or D-NNNN."
|
|
8644
8734
|
)
|
|
8645
8735
|
continue
|
|
8646
|
-
|
|
8647
|
-
clarifications.get(ref)
|
|
8648
|
-
if ref.startswith("C-")
|
|
8649
|
-
else decisions.get(ref)
|
|
8650
|
-
)
|
|
8651
|
-
if target is None:
|
|
8736
|
+
if _deviation_target(ref, clarifications, decisions, carried_rows) is None:
|
|
8652
8737
|
failures.append(
|
|
8653
8738
|
f"final-report data.json: requirementCoverage `{row_id}` "
|
|
8654
8739
|
f"decisionRef `{ref}` does not exist in this report."
|
|
@@ -8664,12 +8749,13 @@ def _validate_deviation_disposition(
|
|
|
8664
8749
|
refs: list[str],
|
|
8665
8750
|
clarifications: dict,
|
|
8666
8751
|
failures: list[str],
|
|
8752
|
+
carried: dict | None = None,
|
|
8667
8753
|
) -> None:
|
|
8754
|
+
carried_rows = carried or {}
|
|
8668
8755
|
if disposition == "accepted":
|
|
8669
8756
|
confirmed = any(
|
|
8670
8757
|
ref.startswith("C-")
|
|
8671
|
-
and
|
|
8672
|
-
and bool(str(clarifications[ref].get("userInput") or "").strip())
|
|
8758
|
+
and _deviation_is_user_confirmed(ref, clarifications, carried_rows)
|
|
8673
8759
|
for ref in refs
|
|
8674
8760
|
)
|
|
8675
8761
|
if not confirmed:
|
|
@@ -8688,7 +8774,9 @@ def _validate_deviation_disposition(
|
|
|
8688
8774
|
if blocked is None:
|
|
8689
8775
|
return
|
|
8690
8776
|
clarification_id = blocked.group(1)
|
|
8691
|
-
clarification = clarifications.get(clarification_id)
|
|
8777
|
+
clarification = clarifications.get(clarification_id) or carried_rows.get(
|
|
8778
|
+
clarification_id
|
|
8779
|
+
)
|
|
8692
8780
|
if clarification is None:
|
|
8693
8781
|
failures.append(
|
|
8694
8782
|
f"final-report data.json: requirementCoverage `{row_id}` "
|
|
@@ -8706,7 +8794,9 @@ def _validate_deviation_disposition(
|
|
|
8706
8794
|
)
|
|
8707
8795
|
|
|
8708
8796
|
|
|
8709
|
-
def _validate_requirement_deviations(
|
|
8797
|
+
def _validate_requirement_deviations(
|
|
8798
|
+
data: dict, failures: list[str], *, carried: dict | None = None,
|
|
8799
|
+
) -> None:
|
|
8710
8800
|
"""Require documented deviations to reference real decisions and approval."""
|
|
8711
8801
|
planning = data.get("implementationPlanning")
|
|
8712
8802
|
if not isinstance(planning, dict):
|
|
@@ -8721,15 +8811,26 @@ def _validate_requirement_deviations(data: dict, failures: list[str]) -> None:
|
|
|
8721
8811
|
for row in (planning.get("decisionDrafts") or [])
|
|
8722
8812
|
if isinstance(row, dict) and row.get("number")
|
|
8723
8813
|
}
|
|
8814
|
+
carried_rows = carried or {}
|
|
8724
8815
|
for row in planning.get("requirementCoverage") or []:
|
|
8725
8816
|
if not isinstance(row, dict) or row.get("status") != "documented-deviation":
|
|
8726
8817
|
continue
|
|
8727
8818
|
row_id = row.get("id") or "<row>"
|
|
8728
8819
|
refs = _resolved_deviation_refs(
|
|
8729
|
-
row_id,
|
|
8820
|
+
row_id,
|
|
8821
|
+
row.get("decisionRefs"),
|
|
8822
|
+
clarifications,
|
|
8823
|
+
decisions,
|
|
8824
|
+
failures,
|
|
8825
|
+
carried_rows,
|
|
8730
8826
|
)
|
|
8731
8827
|
_validate_deviation_disposition(
|
|
8732
|
-
row_id,
|
|
8828
|
+
row_id,
|
|
8829
|
+
row.get("approvalDisposition"),
|
|
8830
|
+
refs,
|
|
8831
|
+
clarifications,
|
|
8832
|
+
failures,
|
|
8833
|
+
carried_rows,
|
|
8733
8834
|
)
|
|
8734
8835
|
|
|
8735
8836
|
|
|
@@ -9518,15 +9619,35 @@ def _validate_convergence_rounds_match_manifest(
|
|
|
9518
9619
|
)
|
|
9519
9620
|
|
|
9520
9621
|
|
|
9521
|
-
def _validate_convergence_states(
|
|
9522
|
-
|
|
9622
|
+
def _validate_convergence_states(
|
|
9623
|
+
run_dir, failures, run_manifest: dict | None = None, project_root: Path | None = None,
|
|
9624
|
+
) -> None:
|
|
9625
|
+
"""이번 런이 가리키는 수렴 상태만 본다. 디렉터리의 옛 seq 파일은 건너뛴다."""
|
|
9523
9626
|
from pathlib import Path as _Path
|
|
9524
9627
|
|
|
9525
|
-
|
|
9526
|
-
if
|
|
9527
|
-
|
|
9528
|
-
|
|
9529
|
-
|
|
9628
|
+
declared = (run_manifest or {}).get("convergenceStatePath")
|
|
9629
|
+
if isinstance(declared, str) and declared.strip():
|
|
9630
|
+
path = _Path(declared.strip())
|
|
9631
|
+
if not path.is_absolute():
|
|
9632
|
+
if project_root is None:
|
|
9633
|
+
return
|
|
9634
|
+
path = project_root / path
|
|
9635
|
+
paths = [path]
|
|
9636
|
+
else:
|
|
9637
|
+
state_dir = _Path(run_dir) / "state"
|
|
9638
|
+
if not state_dir.is_dir():
|
|
9639
|
+
return
|
|
9640
|
+
paths = [
|
|
9641
|
+
state_path
|
|
9642
|
+
for state_path in sorted(state_dir.glob("convergence-*.json"))
|
|
9643
|
+
if not state_path.name.startswith(_CONVERGENCE_INTERMEDIATE_PREFIXES)
|
|
9644
|
+
]
|
|
9645
|
+
for state_path in paths:
|
|
9646
|
+
if not state_path.is_file():
|
|
9647
|
+
if isinstance(declared, str) and declared.strip():
|
|
9648
|
+
failures.append(
|
|
9649
|
+
f"convergence state {state_path.name}: missing declared artifact"
|
|
9650
|
+
)
|
|
9530
9651
|
continue
|
|
9531
9652
|
try:
|
|
9532
9653
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
@@ -10355,7 +10476,9 @@ def main() -> int:
|
|
|
10355
10476
|
run_dir = report_path.parent.parent
|
|
10356
10477
|
_validate_requirements_discovery_fanout(run_dir, failures, brief_path)
|
|
10357
10478
|
# Phase-agnostic: convergence runs in every finding-producing phase.
|
|
10358
|
-
_validate_convergence_states(
|
|
10479
|
+
_validate_convergence_states(
|
|
10480
|
+
report_path.parent.parent, failures, run_manifest, project_root
|
|
10481
|
+
)
|
|
10359
10482
|
_validate_convergence_rounds_match_manifest(
|
|
10360
10483
|
report_path.parent.parent, run_manifest, failures
|
|
10361
10484
|
)
|