okstra 0.151.1 → 0.153.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.md +1 -1
- package/bin/okstra +7 -0
- package/docs/cli.md +5 -1
- package/docs/for-ai/skills/okstra-schedule-gen.md +152 -232
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-antigravity-exec.sh +11 -6
- package/runtime/bin/okstra-wrapper-agy-stream.py +61 -0
- package/runtime/prompts/lead/convergence.md +3 -2
- package/runtime/prompts/lead/plan-body-verification.md +30 -1
- package/runtime/prompts/profiles/implementation-planning.md +6 -2
- package/runtime/python/okstra_ctl/container.py +9 -10
- package/runtime/python/okstra_ctl/convergence_engine.py +2 -1
- package/runtime/python/okstra_ctl/handoff.py +4 -8
- package/runtime/python/okstra_ctl/implementation_outcome.py +10 -56
- package/runtime/python/okstra_ctl/model_discovery.py +22 -1
- package/runtime/python/okstra_ctl/plan_run_root.py +15 -8
- package/runtime/python/okstra_ctl/run.py +8 -54
- package/runtime/python/okstra_ctl/schedule_semantics.py +1249 -0
- package/runtime/python/okstra_ctl/stage_map.py +288 -0
- package/runtime/python/okstra_ctl/wizard.py +24 -35
- package/runtime/python/okstra_project/state.py +19 -5
- package/runtime/skills/okstra-schedule-gen/SKILL.md +75 -35
- package/runtime/templates/reports/schedule.template.md +9 -9
- package/runtime/validators/validate-implementation-plan-stages.py +173 -70
- package/runtime/validators/validate-run.py +136 -0
- package/runtime/validators/validate-schedule.py +78 -10
- package/src/commands/inspect/stage-map.mjs +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Split `agy --output-format stream-json` output for okstra-antigravity-exec.sh.
|
|
3
|
+
|
|
4
|
+
Everything agy emits is appended to the live log, so the log becomes an audit
|
|
5
|
+
trail of what the worker actually did — which tools it called, on which paths,
|
|
6
|
+
for how long. In the default `text` format agy prints only its closing summary,
|
|
7
|
+
so an antigravity dispatch left ~2KB of self-report against codex's ~487KB of
|
|
8
|
+
tool trace, and whether the worker ever opened the evidence it was asked to
|
|
9
|
+
verify could not be checked from outside.
|
|
10
|
+
|
|
11
|
+
stdout stays what the dispatcher's contract expects: the final response text
|
|
12
|
+
only, so the subagent's captured output is unchanged by the format switch.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> int:
|
|
21
|
+
if len(sys.argv) < 2:
|
|
22
|
+
print("usage: okstra-wrapper-agy-stream.py <log-path>", file=sys.stderr)
|
|
23
|
+
return 2
|
|
24
|
+
log_path = sys.argv[1]
|
|
25
|
+
final: str | None = None
|
|
26
|
+
# Lines agy emitted that are not stream-json events. Normally empty; kept so
|
|
27
|
+
# a non-JSON emitter (a stub, an older agy, a crash banner) still reaches
|
|
28
|
+
# stdout instead of being silently swallowed.
|
|
29
|
+
passthrough: list[str] = []
|
|
30
|
+
with open(log_path, "a", encoding="utf-8") as log:
|
|
31
|
+
for line in sys.stdin:
|
|
32
|
+
log.write(line)
|
|
33
|
+
log.flush()
|
|
34
|
+
stripped = line.strip()
|
|
35
|
+
if not stripped:
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
event = json.loads(stripped)
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
passthrough.append(line)
|
|
41
|
+
continue
|
|
42
|
+
if isinstance(event, dict) and event.get("event") == "result":
|
|
43
|
+
result = event.get("result")
|
|
44
|
+
if isinstance(result, dict):
|
|
45
|
+
final = result.get("response") or ""
|
|
46
|
+
if final is not None:
|
|
47
|
+
sys.stdout.write(final)
|
|
48
|
+
elif passthrough:
|
|
49
|
+
sys.stdout.writelines(passthrough)
|
|
50
|
+
else:
|
|
51
|
+
print(
|
|
52
|
+
"okstra-wrapper-agy-stream: no result event in agy output — "
|
|
53
|
+
f"see {log_path}",
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
56
|
+
sys.stdout.flush()
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
raise SystemExit(main())
|
|
@@ -204,7 +204,8 @@ hard_refutes = [v for v in disagrees if v.disagreeBasis == "counter-evidence"]
|
|
|
204
204
|
all_others_disagree = (every non-discoverer non-error vote is "disagree")
|
|
205
205
|
|
|
206
206
|
IF len(disagrees) == 0:
|
|
207
|
-
resolve F as "
|
|
207
|
+
resolve F as "partial-consensus" if SUPPLEMENT/caveat votes are a majority of
|
|
208
|
+
non-error votes, otherwise "full-consensus"
|
|
208
209
|
ELIF all_others_disagree:
|
|
209
210
|
resolve F as "worker-unique" # only the discoverer still holds it
|
|
210
211
|
ELIF len(hard_refutes) >= 1:
|
|
@@ -219,7 +220,7 @@ ELSE:
|
|
|
219
220
|
|
|
220
221
|
`contested` remains a **final classification only** (per §"Scope and Terminology"): a disputed finding is carried forward through intermediate rounds and labelled `contested` only at the last executed round. For `requirements-discovery` (`effectiveMaxRounds = 1`) the single round IS the last round, so a split-with-hard-refute finding is labelled `contested` in that one round. The final-classifier block of §"Convergence Algorithm" honours this: its first branch classifies an adversarially carried-forward finding `contested` regardless of the AGREE tally, so the two sections cannot assign the same finding different labels.
|
|
221
222
|
|
|
222
|
-
Design intent: one `counter-evidence` refute denies a claim consensus (it cannot rise above `contested` however many others AGREE); later-round agreement does not erase that refutation history. The only resolution that overrides prior `counter-evidence` is a later round where every non-discoverer non-error worker disagrees, producing `worker-unique`. A lone `burden-not-met` doubt does not sink an otherwise-surviving claim — only a majority of them does. When every non-discoverer refutes (all_others_disagree) the finding is worker-unique regardless of refute basis — only the discoverer still holds it. A
|
|
223
|
+
Design intent: one `counter-evidence` refute denies a claim consensus (it cannot rise above `contested` however many others AGREE); later-round agreement does not erase that refutation history. The only resolution that overrides prior `counter-evidence` is a later round where every non-discoverer non-error worker disagrees, producing `worker-unique`. A lone `burden-not-met` doubt does not sink an otherwise-surviving claim — only a majority of them does. When every non-discoverer refutes (all_others_disagree) the finding is worker-unique regardless of refute basis — only the discoverer still holds it. A caveat is weighed the same way a weak doubt is: with zero disagrees, SUPPLEMENT lands partial-consensus only when caveats are a **majority** of the non-error votes. A single verifier's scope note does not by itself deny a claim the rest of the roster passed cleanly — the caveat is still recorded in the dissent log either way, so the majority rule changes the label, never the record. (The collaborative classifier is more permissive still: there SUPPLEMENT counts as full agreement at any count.)
|
|
223
224
|
|
|
224
225
|
## Re-verification Dispatch
|
|
225
226
|
|
|
@@ -194,7 +194,7 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
|
|
|
194
194
|
**Record the cause, not just the outcome.** The gate value names the outcome; `planBodyVerification.gateBlockedBy` (array) names every input that blocked it — `majority-disagree`, `coverage-gap`, `non-result`. Two independent inputs can block: a `majority-disagree` plan item, and a Requirement Coverage `gap` / `blocked C-NNN` row (`prompts/profiles/implementation-planning.md` §"Requirement Coverage"). A coverage-only block still renders as `blocked-by-disagreement` because that is the only blocking non-abort value, so **without `gateBlockedBy` the report asserts a worker disagreement that never happened** and the reader hunts for a dissent that does not exist. Leave the array empty for a passing gate. **Enforced:** `validators/validate-run.py` `_validate_gate_blocked_by` cross-checks the declared causes against the recorded verdicts and coverage rows, and fails a passing gate that has a blocking coverage row — the coverage rule was prose-only before.
|
|
195
195
|
|
|
196
196
|
**A coverage row citing this run's own `C-NNN` is not an independent blocker.** When a coverage row's `blocked C-NNN` points at a clarification that step 8 below promoted from a `majority-disagree` item in *this same run*, that blocker is already counted once as the plan item. Counting it again as a coverage gap makes the run block on a clarification it just authored, and the row carries into the next run as a fresh blocker — the Requirement Coverage ↔ Clarification cycle. Such rows are excluded from `coverage-gap`. **Enforced:** `validators/validate-run.py` `_independent_coverage_blockers`.
|
|
197
|
-
6. Lead records `planBodyVerification.participatingAnalysers` as `{rostered, voting}` — how many analysers the roster carried, and how many actually returned a non-error vote. The gate arithmetic is unchanged, but a shrunken roster loosens it silently: with two participating analysers a 1-AGREE / 1-DISAGREE split is a tie, so it never reaches `majority-disagree` and the dissent passes as `dissent-isolated`. A reader comparing two runs' gate values cannot see that without this pair. **Enforced:** `validators/validate-run.py` `_validate_participating_analysers` recomputes `voting` from the recorded verdicts and fails a declared figure the table denies. Then lead writes `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (schema below), **appending this round** — one new `roundHistory[]` entry plus this round's votes on each verified item's `planItems[].rounds[]`. The file accumulates across rounds; it is never truncated to the latest one. Lead then populates `### 5.5.9 Plan Body Verification` in the final report's data.json (`implementationPlanning.planBodyVerification`, schema `schemas/final-report-v1.0.schema.json`; template at `templates/reports/final-report.template.md`). The §5.5.9 body is **grouped by plan item**: `planItems[]`, each carrying its `id`, its plain-language `subject` (rendered as the item heading), an optional `sourceSection`, an optional `clarificationId` (the `C-<N>` this item blocks on when `majority-disagree`), and a `verdicts[]` list (`worker / verdict / breakageKind / note`) — one verdict row per worker under that item. The renderer prints three fixed legends (gate values, verdict tokens, breakage kinds a–f) so the reader can decode every cell without opening this spec. The older flat `#### Verdict details` table (`Plan item / Worker / …`, one row per plan-item × worker pair) is superseded by the grouped layout — it hid *what* each vote was about behind a bare `P-*` ID; the subject heading is the fix. The validator's `Plan Body Verification` + `Gate result:` substring checks still gate this section.
|
|
197
|
+
6. Lead records `planBodyVerification.participatingAnalysers` as `{rostered, voting}` — how many analysers the roster carried, and how many actually returned a non-error vote. The gate arithmetic is unchanged, but a shrunken roster loosens it silently: with two participating analysers a 1-AGREE / 1-DISAGREE split is a tie, so it never reaches `majority-disagree` and the dissent passes as `dissent-isolated`. A reader comparing two runs' gate values cannot see that without this pair. **Enforced:** `validators/validate-run.py` `_validate_participating_analysers` recomputes `voting` from the recorded verdicts and fails a declared figure the table denies. That pair still counts only *whether* a worker voted: an analyser that answers the same verdict to every item is carried in `voting` as a third opinion while contributing no refutation signal, so the gate reads as a three-way cross-check backed by two. **Enforced (advisory):** `validators/validate-run.py` `_detect_uniform_verifier` reports any worker whose every vote in the round was one verdict, with its item count — it does not fail the run, because a unanimous round is also a legitimate outcome and no ratio separates the two reliably. Then lead writes `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (schema below), **appending this round** — one new `roundHistory[]` entry plus this round's votes on each verified item's `planItems[].rounds[]`. The file accumulates across rounds; it is never truncated to the latest one. Lead then populates `### 5.5.9 Plan Body Verification` in the final report's data.json (`implementationPlanning.planBodyVerification`, schema `schemas/final-report-v1.0.schema.json`; template at `templates/reports/final-report.template.md`). The §5.5.9 body is **grouped by plan item**: `planItems[]`, each carrying its `id`, its plain-language `subject` (rendered as the item heading), an optional `sourceSection`, an optional `clarificationId` (the `C-<N>` this item blocks on when `majority-disagree`), and a `verdicts[]` list (`worker / verdict / breakageKind / note`) — one verdict row per worker under that item. The renderer prints three fixed legends (gate values, verdict tokens, breakage kinds a–f) so the reader can decode every cell without opening this spec. The older flat `#### Verdict details` table (`Plan item / Worker / …`, one row per plan-item × worker pair) is superseded by the grouped layout — it hid *what* each vote was about behind a bare `P-*` ID; the subject heading is the fix. The validator's `Plan Body Verification` + `Gate result:` substring checks still gate this section.
|
|
198
198
|
7. **Self-fix loop (up to `selfFixMaxRounds`, targeting planner-fixable defects).** After aggregation, while at least one `majority-disagree` item has a majority of its `DISAGREE` verdicts at `fixability == planner-fixable`, lead runs self-fix rounds **before** promoting anything to the user:
|
|
199
199
|
- **Group the targets by cause before instructing (BLOCKING).** Blocked items are usually several derivatives of one defect — one constant declared twice, one responsibility given two owners — and the coverage rows that cite them fail as a consequence, not independently. Lead MUST partition this round's targets into cause groups and instruct each group as **"remove this cause"**, naming the derivatives it accounts for. **Handing report-writer a bare item list is forbidden**: patched one at a time, each correction leaves the sibling sections still asserting the old value, so the next round re-finds the same family and the budget drains without converging. Record the partition in `planBodyVerification.selfFixGroups[]` (`round`, `causeSummary`, `itemIds`). One group per item is a legitimate outcome only when the items genuinely share no cause — recorded that way, it is a visible diagnosis rather than a skipped one. **Enforced:** `validators/validate-run.py` `_validate_self_fix_grouping` requires the partition, ties `selfFixRoundsApplied` to the highest recorded round, and fails any corrected item that belongs to no group.
|
|
200
200
|
- lead instructs report-writer to rewrite the items in each cause group (NOT a full draft regeneration; procedure in [report-writer](./report-writer.md) §"Self-fix rewrite").
|
|
@@ -430,6 +430,35 @@ When `config.adversarial == true`, the lead prepends the adversarial framing fro
|
|
|
430
430
|
|
|
431
431
|
The "Reverify prompt: required-reading suppression" rule in [convergence](./convergence.md) (lightweight mode does NOT inject a `[Required reading]` clause) applies here as well.
|
|
432
432
|
|
|
433
|
+
## Re-verification rounds (round 2+) — carry the dissent forward (BLOCKING)
|
|
434
|
+
|
|
435
|
+
The template above is the round-1 prompt. A round 2+ prompt exists to settle the *previous* round's dissent, so it MUST carry that dissent forward. Re-rendering the round-1 template alone is a contract violation: with no record of what was objected to, the dissenting worker restates its verdict unchanged and its peers re-judge from nothing, so the loop spends its whole `selfFixMaxRounds` budget re-deriving the same split. Telling the worker that a prior verdict "carries no weight" is the same defect stated as an instruction — prior dissent is evidence about the plan, and discarding it is what makes the round repeat.
|
|
436
|
+
|
|
437
|
+
Changes from the round-1 template:
|
|
438
|
+
|
|
439
|
+
- The heading reads `(round <N>)`.
|
|
440
|
+
- Every re-dispatched item carries a `**Prior round dissent**` block, built from the state file's `planItems[].rounds[].votes` and the item's `selfFixNote`:
|
|
441
|
+
|
|
442
|
+
```text
|
|
443
|
+
**Prior round dissent** (round <N-1>):
|
|
444
|
+
- <worker>: DISAGREE(<kind>) — <that worker's verbatim explanation>
|
|
445
|
+
- <worker>: SUPPLEMENT — <verbatim explanation>
|
|
446
|
+
**What the planner changed**: <the item's selfFixNote, or "no correction — this item's
|
|
447
|
+
text shifted under a neighbouring rewrite">
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
An item re-dispatched as `needs-reverify` (its peer returned a non-result, so nothing was objected to) carries the block with `- none — the peer vote was a non-result` in place of the verdict lines.
|
|
451
|
+
|
|
452
|
+
- The `## Instructions` block gains this paragraph, which supersedes any "judge the corrected text on its own merits" framing:
|
|
453
|
+
|
|
454
|
+
> Each item below was objected to in the previous round, or its text shifted under a neighbouring correction. Read the prior dissent and the planner's correction before judging the CURRENT text. If you raised the prior objection and still DISAGREE, you MUST state what you re-opened after the rewrite and why the correction is insufficient — restating your previous explanation without engaging the correction is not a valid verdict. If the correction resolves your objection, say so and AGREE.
|
|
455
|
+
|
|
456
|
+
- The response format gains one line per item, directly under `**Verdict**`:
|
|
457
|
+
|
|
458
|
+
`**Prior dissent**: resolved | unresolved | none — <which prior objection, and what the current text does about it>`
|
|
459
|
+
|
|
460
|
+
**Enforced:** `validators/validate-run.py` `_validate_reverify_prompt_carries_dissent` fails a run whose `plan-verify-r<N>` (N ≥ 2) prompt file lacks the `**Prior round dissent**` block.
|
|
461
|
+
|
|
433
462
|
## Worker non-result handling in plan-body round (BLOCKING)
|
|
434
463
|
|
|
435
464
|
Mirrors finding convergence ([convergence](./convergence.md) §"Worker failure handling in reverify"). Concretely:
|
|
@@ -84,8 +84,12 @@
|
|
|
84
84
|
- The plan lives in `data.json` under `implementationPlanning`, and `schemas/final-report-v2.0.schema.json` requires every one of these keys: `optionCandidates`, `tradeoffMatrix`, `recommendedOption`, `stageMap`, `stages`, `dependencyMigrationRisk`, `validationChecklist`, `rollbackStrategy`, `requirementCoverage`, `planBodyVerification`, `crossProjectDependencies`, `decisionDrafts`, `skippedAdrCandidates`, `variationPointAnalysis`, `userNarrative`. A missing block fails schema validation; there is nothing to satisfy by naming a heading. (Approval is not a body section — it is the YAML frontmatter `approved` field.)
|
|
85
85
|
- Each `stages[]` entry requires `stage`, `title`, `sliceValue`, `acceptance`, `carryIn`, `stepwiseExecution` (1–6 rows), `exitContract`, and `stageValidation`. Each `stageMap[]` row requires `stage`, `title`, `dependsOn`, `stepCount`, `exitContractSummary`.
|
|
86
86
|
- Beyond the schema, `validators/validate-run.py` reads the same data.json for `_validate_planning_conformance_declared`, `_validate_end_state_coverage`, `_validate_requirement_provenance`, `_validate_stage_has_requirement`, and `_validate_plan_body_state_file`. These run for every planning report regardless of schema version.
|
|
87
|
-
- **Do not chase English heading substrings.** `PLANNING_REQUIRED_SECTIONS` and the
|
|
88
|
-
|
|
87
|
+
- **Do not chase English heading substrings.** `PLANNING_REQUIRED_SECTIONS` and the Markdown scan in `collect_validation_errors` live inside `validate_phase_boundary`, which returns immediately when `schemaVersion == "2.0"` — they gate historical v1 Markdown only. The v2 AI-handoff template renders nine headings and serialises the plan as JSON beneath them, so those substrings cannot appear, and a report is not defective for lacking them.
|
|
88
|
+
- Per-stage vertical slice and TDD contract (BLOCKING — enforced on the data, not on heading tokens):
|
|
89
|
+
- Every stage declares `sliceValue`, `acceptance`, and the three cases `testCaseSuccess` / `testCaseBoundary` / `testCaseFailure` — happy path, edge/boundary input, failure input. **Enforced:** the v2 schema's `if not tddExemption then require` conditional on `ImplementationPlanStage`.
|
|
90
|
+
- The first `stepwiseExecution` row's `action` starts with `RED:` and its `expected` reads FAIL; some later row's `action` starts with `GREEN:` and its `expected` reads PASS. **Enforced (S10c):** `collect_data_validation_errors` in `validators/validate-implementation-plan-stages.py`, run from `validate-run.py` `_append_stage_data_failures`.
|
|
91
|
+
- `tddExemption` waives both rules above, and only for `doc-only`, `config-only`, or `pure-rename` work. An empty or arbitrary reason waives nothing. **Enforced (S10e):** same function — the schema alone cannot reject it, because it types the field as a plain string and keys its conditional on the property merely being present.
|
|
92
|
+
- `stageMap[].dependsOn` must form a DAG (no self-dependency, no unknown stage, no cycle), each row's `stepCount` must equal its stage's actual `stepwiseExecution` row count, and two `(none)`-dependency stages must not name the same file in their `exitContract` — they run as concurrent implementation runs in separate worktrees. **Enforced (S8/S4/S9):** same function.
|
|
89
93
|
- Required deliverable shape (final report, in addition to the standard sections):
|
|
90
94
|
- at least two implementation options. **Each option must include**:
|
|
91
95
|
- **File Structure**: an explicit list of files to create / modify / delete with each file's responsibility (one-line each). Use the form `Create: path — responsibility` / `Modify: path:line-range — change summary` / `Delete: path — reason`. Write every `path` in full and `<PROJECT_ROOT>`-relative — never ellipsis-abbreviated (`…` / `...` / a trailing `/…`); an abbreviated path does not resolve and is rejected by plan-body verification as a kind-b path mismatch.
|
|
@@ -27,6 +27,7 @@ from pathlib import Path
|
|
|
27
27
|
from typing import Callable, Iterable
|
|
28
28
|
|
|
29
29
|
from .stage_targets import PrepareError # 단일 PrepareError 타입 재노출(run.py 와 공유)
|
|
30
|
+
from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
|
|
30
31
|
|
|
31
32
|
OKSTRA_LABEL_KEYS = ("okstra.task-key", "okstra.project-name", "okstra.run-trace")
|
|
32
33
|
|
|
@@ -530,16 +531,14 @@ def _resolve_up_inputs(project_root: Path, task_key: str) -> dict:
|
|
|
530
531
|
|
|
531
532
|
|
|
532
533
|
def _plan_stage_map(approved_plan_path: str) -> list[dict]:
|
|
533
|
-
"""approved
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
from .run import _parse_stage_map_into_ctx
|
|
542
|
-
return _parse_stage_map_into_ctx(approved_plan_path)
|
|
534
|
+
"""Parse the approved plan's complete Stage Map for container gating."""
|
|
535
|
+
try:
|
|
536
|
+
return stage_map_records(parse_stage_map_file(Path(approved_plan_path)))
|
|
537
|
+
except StageMapError as exc:
|
|
538
|
+
raise PrepareError(
|
|
539
|
+
"approved-plan 의 Stage Map 을 신뢰할 수 없어 거부합니다 "
|
|
540
|
+
f"({approved_plan_path}): {exc.reason}. plan 의 Stage Map 을 점검하세요."
|
|
541
|
+
) from exc
|
|
543
542
|
|
|
544
543
|
|
|
545
544
|
def _verify_compose_present(worktree_root: Path) -> Path:
|
|
@@ -191,9 +191,10 @@ def classify_adversarial_round(
|
|
|
191
191
|
return None
|
|
192
192
|
disagrees = [vote for vote in usable if vote["verdict"] == "disagree"]
|
|
193
193
|
if not disagrees:
|
|
194
|
+
caveats = sum(vote["verdict"] == "supplement" for vote in usable)
|
|
194
195
|
return (
|
|
195
196
|
"partial-consensus"
|
|
196
|
-
if
|
|
197
|
+
if caveats > len(usable) / 2
|
|
197
198
|
else "full-consensus"
|
|
198
199
|
)
|
|
199
200
|
if len(disagrees) == len(usable):
|
|
@@ -15,6 +15,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
15
15
|
from . import consumers, stage_targets, worktree_registry
|
|
16
16
|
from .final_report_paths import final_report_markdown_path
|
|
17
17
|
from .paths import RunRef
|
|
18
|
+
from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
|
|
18
19
|
from .worktree import (compute_branch_name, compute_worktree_path,
|
|
19
20
|
main_worktree_path, is_dirty_excluding_okstra,
|
|
20
21
|
nested_worktree_excludes, is_ancestor, merge_branch,
|
|
@@ -411,8 +412,6 @@ def _parse_stages_csv(raw: str) -> List[int]:
|
|
|
411
412
|
def main(argv: Optional[list] = None) -> int:
|
|
412
413
|
import argparse
|
|
413
414
|
|
|
414
|
-
from .run import _parse_stage_map_into_ctx, PrepareError
|
|
415
|
-
|
|
416
415
|
p = argparse.ArgumentParser(prog="okstra handoff")
|
|
417
416
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
418
417
|
|
|
@@ -458,14 +457,14 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
458
457
|
a = p.parse_args(argv)
|
|
459
458
|
try:
|
|
460
459
|
if a.cmd == "eligible":
|
|
461
|
-
stage_map =
|
|
460
|
+
stage_map = stage_map_records(parse_stage_map_file(Path(a.approved_plan)))
|
|
462
461
|
rows = consumers.read_consumers(Path(a.plan_run_root))
|
|
463
462
|
out = {"stages": compute_eligibility(stage_map, rows)}
|
|
464
463
|
elif a.cmd == "assemble":
|
|
465
464
|
out = assemble(
|
|
466
465
|
project_root=Path(a.project_root).resolve(),
|
|
467
466
|
plan_run_root=Path(a.plan_run_root),
|
|
468
|
-
stage_map=
|
|
467
|
+
stage_map=stage_map_records(parse_stage_map_file(Path(a.approved_plan))),
|
|
469
468
|
stages=_parse_stages_csv(a.stages), base_branch=a.base,
|
|
470
469
|
work_category=a.work_category, project_id=a.project_id,
|
|
471
470
|
task_group=a.task_group, task_id=a.task_id)
|
|
@@ -490,10 +489,7 @@ def main(argv: Optional[list] = None) -> int:
|
|
|
490
489
|
except HandoffError as exc:
|
|
491
490
|
print(json.dumps({"error": str(exc)}, ensure_ascii=False))
|
|
492
491
|
return 1
|
|
493
|
-
except
|
|
494
|
-
# _parse_stage_map_into_ctx 가 빈/손상 Stage Map 을 거부하면 PrepareError 가
|
|
495
|
-
# 오른다(HandoffError 의 형제 — 위 except 로 안 잡힘). JSON 계약을 깨고
|
|
496
|
-
# traceback 으로 죽지 않도록 error envelope + exit 1 로 처리한다.
|
|
492
|
+
except StageMapError as exc:
|
|
497
493
|
print(json.dumps({"error": str(exc)}, ensure_ascii=False))
|
|
498
494
|
return 1
|
|
499
495
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
@@ -12,6 +12,7 @@ from .consumers import (
|
|
|
12
12
|
read_stage_consumer_state,
|
|
13
13
|
)
|
|
14
14
|
from .paths import RunRef, task_manifest_file
|
|
15
|
+
from .stage_map import StageMapError, load_task_stage_map
|
|
15
16
|
from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
|
|
16
17
|
|
|
17
18
|
|
|
@@ -36,9 +37,15 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
|
|
|
36
37
|
if not plan_run_root.is_dir():
|
|
37
38
|
return ImplementationOutcome(completed=False, reason="implementation-planning run root missing")
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
try:
|
|
41
|
+
stage_snapshot = load_task_stage_map(task_root, manifest)
|
|
42
|
+
except StageMapError as exc:
|
|
43
|
+
return ImplementationOutcome(
|
|
44
|
+
completed=False, reason=f"{exc.code}: {exc.reason}"
|
|
45
|
+
)
|
|
46
|
+
if stage_snapshot.state == "missing":
|
|
47
|
+
return ImplementationOutcome(completed=False, reason="stage map missing")
|
|
48
|
+
stage_map = stage_snapshot.stages
|
|
42
49
|
|
|
43
50
|
consumers = read_stage_consumer_state(plan_run_root, recover_from_carry=True)
|
|
44
51
|
required_stages = {stage["stage_number"] for stage in stage_map}
|
|
@@ -114,59 +121,6 @@ def _load_json(path: Path) -> dict[str, Any]:
|
|
|
114
121
|
return data if isinstance(data, dict) else {}
|
|
115
122
|
|
|
116
123
|
|
|
117
|
-
def load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
118
|
-
plan_path = _source_plan_path(task_root, manifest)
|
|
119
|
-
if not plan_path.is_file():
|
|
120
|
-
return []
|
|
121
|
-
from .run import PrepareError, _load_parsed_stage_map, _stage_map_reject_detail
|
|
122
|
-
|
|
123
|
-
try:
|
|
124
|
-
stages, errs = _load_parsed_stage_map(plan_path.read_text(encoding="utf-8"))
|
|
125
|
-
except PrepareError:
|
|
126
|
-
return []
|
|
127
|
-
if _stage_map_reject_detail(stages, errs) is not None:
|
|
128
|
-
return []
|
|
129
|
-
return [
|
|
130
|
-
{
|
|
131
|
-
"stage_number": stage.stage_number,
|
|
132
|
-
"title": stage.title,
|
|
133
|
-
"depends_on": list(stage.depends_on),
|
|
134
|
-
"step_count": stage.step_count,
|
|
135
|
-
}
|
|
136
|
-
for stage in stages
|
|
137
|
-
]
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def _source_plan_path(task_root: Path, manifest: dict[str, Any]) -> Path:
|
|
141
|
-
carry_dir = RunRef.from_task_root(task_root, "implementation").carry_dir
|
|
142
|
-
for carry_path in sorted(carry_dir.glob("stage-*.json")):
|
|
143
|
-
value = _load_json(carry_path).get("sourcePlanPath")
|
|
144
|
-
if isinstance(value, str) and value:
|
|
145
|
-
return _resolve_relative(task_root, value)
|
|
146
|
-
|
|
147
|
-
latest = manifest.get("latestReportPath")
|
|
148
|
-
if isinstance(latest, str) and "implementation-planning" in latest:
|
|
149
|
-
return _resolve_relative(task_root, latest)
|
|
150
|
-
|
|
151
|
-
reports = RunRef.from_task_root(task_root, "implementation-planning").reports_dir
|
|
152
|
-
candidates = sorted(reports.glob("final-report-implementation-planning-*.md"))
|
|
153
|
-
return candidates[-1] if candidates else Path()
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def _resolve_relative(task_root: Path, value: str) -> Path:
|
|
157
|
-
path = Path(value)
|
|
158
|
-
if path.is_absolute():
|
|
159
|
-
return path
|
|
160
|
-
project_root = _project_root_from_task_root(task_root)
|
|
161
|
-
project_relative = project_root / path
|
|
162
|
-
if project_relative.exists():
|
|
163
|
-
return project_relative
|
|
164
|
-
task_relative = task_root / path
|
|
165
|
-
if task_relative.exists():
|
|
166
|
-
return task_relative
|
|
167
|
-
return project_relative
|
|
168
|
-
|
|
169
|
-
|
|
170
124
|
def _project_root_from_task_root(task_root: Path) -> Path:
|
|
171
125
|
parts = task_root.resolve().parts
|
|
172
126
|
if ".okstra" not in parts:
|
|
@@ -30,6 +30,27 @@ def role_effort(role: str) -> str:
|
|
|
30
30
|
return ROLE_EFFORT.get(role, _DEFAULT_EFFORT)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# agy lists a tier per model, but the listed slug is not always the model it
|
|
34
|
+
# serves: on agy 1.1.10 a `gemini-3.1-pro-high` session identifies itself as
|
|
35
|
+
# "Gemini 3.6 Flash", while `gemini-3.1-pro-low` correctly identifies as
|
|
36
|
+
# "Gemini 3.1 Pro". The substitution is not visible anywhere in the dispatch —
|
|
37
|
+
# `agy models` lists the slug and the run exits 0 — so it surfaces only as a
|
|
38
|
+
# verifier that never refutes anything: measured over one 63-item plan-body
|
|
39
|
+
# prompt, the high slug returned all-AGREE in 64s while the low slug spent 328s
|
|
40
|
+
# and raised three DISAGREEs, one of them the defect claude and codex both
|
|
41
|
+
# caught that round. Only the `high` tier is affected, so a role asking for any
|
|
42
|
+
# other effort still resolves normally (and a tier agy does not offer still
|
|
43
|
+
# hard-fails below).
|
|
44
|
+
_UNTRUSTED_HIGH_TIER_EXECUTIONS = frozenset({"gemini-3.1-pro"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _dispatch_effort(execution: str, role: str) -> str:
|
|
48
|
+
effort = role_effort(role).lower()
|
|
49
|
+
if effort == "high" and execution in _UNTRUSTED_HIGH_TIER_EXECUTIONS:
|
|
50
|
+
return "low"
|
|
51
|
+
return effort
|
|
52
|
+
|
|
53
|
+
|
|
33
54
|
@lru_cache(maxsize=1)
|
|
34
55
|
def agy_models(agy_bin: str = "agy") -> tuple[str, ...]:
|
|
35
56
|
"""Live `agy models` list, or () when agy is unavailable (non-blocking)."""
|
|
@@ -91,7 +112,7 @@ def normalize_execution_for_dispatch(
|
|
|
91
112
|
# (`gemini-3.1-pro-high`) and rejects the bare slug with "requires --effort"
|
|
92
113
|
# — a flag the wrapper never sends. So the suffixed form is the only
|
|
93
114
|
# dispatchable spelling, including on the un-verified fallback below.
|
|
94
|
-
tiered = f"{execution}-{
|
|
115
|
+
tiered = f"{execution}-{_dispatch_effort(execution, role)}"
|
|
95
116
|
available = agy_models()
|
|
96
117
|
if not available:
|
|
97
118
|
return tiered # discovery unavailable → dispatch anyway, wrapper still guards
|
|
@@ -26,6 +26,17 @@ class PlanRun(NamedTuple):
|
|
|
26
26
|
approved_plan_path: str
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def list_implementation_planning_reports(reports_dir: Path) -> list[Path]:
|
|
30
|
+
"""Return numbered implementation-planning reports in latest-first order."""
|
|
31
|
+
numbered: list[tuple[int, Path]] = []
|
|
32
|
+
for report in reports_dir.glob("final-report-implementation-planning-*.md"):
|
|
33
|
+
match = _FINAL_REPORT_RE.fullmatch(report.name)
|
|
34
|
+
if match:
|
|
35
|
+
numbered.append((int(match.group(1)), report))
|
|
36
|
+
numbered.sort(key=lambda item: item[0], reverse=True)
|
|
37
|
+
return [report for _, report in numbered]
|
|
38
|
+
|
|
39
|
+
|
|
29
40
|
def plan_run_root_from_approved_plan(approved_plan_path: str | Path) -> Path:
|
|
30
41
|
"""approved-plan(final-report) 경로에서 plan_run_root 를 역산한다.
|
|
31
42
|
|
|
@@ -58,21 +69,17 @@ def resolve_plan_run_root_by_task_key(
|
|
|
58
69
|
f"({reports_dir} 없음). 먼저 implementation-planning 을 완료하거나 "
|
|
59
70
|
"approved-plan 경로를 직접 지정하세요."
|
|
60
71
|
)
|
|
61
|
-
candidates: list[tuple[
|
|
62
|
-
for report in reports_dir
|
|
63
|
-
m = _FINAL_REPORT_RE.search(report.name)
|
|
64
|
-
if not m:
|
|
65
|
-
continue
|
|
72
|
+
candidates: list[tuple[Path, Path]] = []
|
|
73
|
+
for report in list_implementation_planning_reports(reports_dir):
|
|
66
74
|
run_root = plan_run_root_from_approved_plan(report)
|
|
67
75
|
done = [r for r in read_consumers(run_root) if r.get("status") == "done"]
|
|
68
76
|
if done:
|
|
69
|
-
candidates.append((
|
|
77
|
+
candidates.append((run_root, report))
|
|
70
78
|
if not candidates:
|
|
71
79
|
raise PrepareError(
|
|
72
80
|
"container up: done 상태의 implementation-planning run 이 없습니다. "
|
|
73
81
|
"stage 를 완료(implementation done)한 뒤 다시 시도하거나 approved-plan "
|
|
74
82
|
"경로를 직접 지정하세요."
|
|
75
83
|
)
|
|
76
|
-
|
|
77
|
-
_, run_root, report = candidates[-1]
|
|
84
|
+
run_root, report = candidates[0]
|
|
78
85
|
return PlanRun(run_root=run_root, approved_plan_path=str(report))
|
|
@@ -15,7 +15,6 @@ state passing, and are read once at the start.
|
|
|
15
15
|
"""
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
-
import importlib.util
|
|
19
18
|
import hashlib
|
|
20
19
|
import json
|
|
21
20
|
import os
|
|
@@ -475,62 +474,17 @@ def _validate_stage_structure(plan_path: str) -> None:
|
|
|
475
474
|
RUN_STEP_BUDGET = _stage_targets.RUN_STEP_BUDGET
|
|
476
475
|
|
|
477
476
|
|
|
478
|
-
def
|
|
479
|
-
"""
|
|
480
|
-
|
|
481
|
-
빈/손상 Stage Map 을 흘려보내면 whole-task 완료 게이트
|
|
482
|
-
(`_resolve_whole_task_target`의 `for stage in stage_map`)가 0회 순회로 vacuous
|
|
483
|
-
통과해 미완 task 를 '완성'으로 배포·검증한다. 빈 파싱(heading rename·표 손상)과
|
|
484
|
-
stage 번호 비단조(중간 행 누락 → S2)의 판정을 한 곳에 모아, run.py 의 prepare
|
|
485
|
-
게이트와 wizard 의 stage picker 가 같은 기준을 상속하게 한다(single-reference)."""
|
|
486
|
-
if stages and not errs:
|
|
487
|
-
return None
|
|
488
|
-
return (
|
|
489
|
-
"; ".join(e.message for e in errs) if errs
|
|
490
|
-
else "'## 5.5 Stage Map' heading 이 없거나 표가 비었습니다")
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
def _load_parsed_stage_map(text: str):
|
|
494
|
-
"""validator 모듈을 로드해 `_parse_stage_map(text)` 의 (stages, errs) 를 돌려준다.
|
|
477
|
+
def _parse_stage_map_into_ctx(plan_path: str) -> list:
|
|
478
|
+
"""Parse the approved plan into context records for execution consumers."""
|
|
479
|
+
from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
|
|
495
480
|
|
|
496
|
-
run.py 와 wizard 가 공유하는 단일 validator-load 경로 — 둘 다 `_STAGE_VALIDATOR_PATH`
|
|
497
|
-
(설치 시 `~/.okstra/lib/validators` 로 배치돼 `parents[2]/validators` 로 해소)로
|
|
498
|
-
수렴해 경로 해소가 갈라지지 않게 한다."""
|
|
499
|
-
spec = importlib.util.spec_from_file_location(
|
|
500
|
-
"_ip_stage_validator", _STAGE_VALIDATOR_PATH
|
|
501
|
-
)
|
|
502
|
-
if spec is None or spec.loader is None:
|
|
503
|
-
raise PrepareError(f"cannot load stage validator at {_STAGE_VALIDATOR_PATH}")
|
|
504
|
-
mod = importlib.util.module_from_spec(spec)
|
|
505
|
-
# Register before exec_module so dataclass field-type resolution can find
|
|
506
|
-
# the module in sys.modules (required on Python 3.9).
|
|
507
|
-
sys.modules["_ip_stage_validator"] = mod
|
|
508
481
|
try:
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
finally:
|
|
512
|
-
sys.modules.pop("_ip_stage_validator", None)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
def _parse_stage_map_into_ctx(plan_path: str) -> list:
|
|
516
|
-
"""Reuse the validator's parser to extract StageMeta dicts for the ctx."""
|
|
517
|
-
text = Path(plan_path).read_text(encoding="utf-8")
|
|
518
|
-
stages, errs = _load_parsed_stage_map(text)
|
|
519
|
-
detail = _stage_map_reject_detail(stages, errs)
|
|
520
|
-
if detail is not None:
|
|
482
|
+
return stage_map_records(parse_stage_map_file(Path(plan_path)))
|
|
483
|
+
except StageMapError as exc:
|
|
521
484
|
raise PrepareError(
|
|
522
|
-
|
|
523
|
-
f"{
|
|
524
|
-
|
|
525
|
-
{
|
|
526
|
-
"stage_number": s.stage_number,
|
|
527
|
-
"title": s.title,
|
|
528
|
-
"depends_on": list(s.depends_on),
|
|
529
|
-
"step_count": s.step_count,
|
|
530
|
-
"exit_contract_summary": s.exit_contract_summary,
|
|
531
|
-
}
|
|
532
|
-
for s in stages
|
|
533
|
-
]
|
|
485
|
+
"approved-plan 의 Stage Map 을 신뢰할 수 없어 거부합니다 "
|
|
486
|
+
f"({plan_path}): {exc.reason}. plan 의 Stage Map 을 점검하세요."
|
|
487
|
+
) from exc
|
|
534
488
|
|
|
535
489
|
|
|
536
490
|
def _apply_cli_approval(path: str) -> str:
|