okstra 0.141.3 → 0.143.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.md +11 -2
- package/docs/cli.md +15 -0
- package/docs/for-ai/skills/okstra-setup.md +8 -0
- package/docs/project-structure-overview.md +6 -0
- package/docs/task-process/error-analysis.md +9 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +4 -2
- package/runtime/prompts/coding-preflight/architectures/hexagonal.md +3 -3
- package/runtime/prompts/coding-preflight/overview.md +1 -1
- package/runtime/prompts/lead/adapters/claude-code.md +2 -2
- package/runtime/prompts/lead/context-loader.md +2 -2
- package/runtime/prompts/lead/convergence.md +5 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +20 -9
- package/runtime/prompts/lead/report-writer.md +4 -3
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -0
- package/runtime/prompts/profiles/_common-contract.md +3 -1
- package/runtime/prompts/profiles/_implementation-deliverable.md +3 -3
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -1
- package/runtime/prompts/profiles/error-analysis.md +5 -1
- package/runtime/prompts/profiles/forbidden-actions.json +0 -1
- package/runtime/prompts/profiles/implementation-planning.md +7 -2
- package/runtime/prompts/profiles/requirements-discovery.md +7 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +28 -3
- package/runtime/python/okstra_ctl/brief_frontmatter.py +56 -0
- package/runtime/python/okstra_ctl/clarification_items.py +99 -5
- package/runtime/python/okstra_ctl/convergence_engine.py +66 -15
- package/runtime/python/okstra_ctl/paths.py +34 -7
- package/runtime/python/okstra_ctl/phase_cleanup.py +235 -0
- package/runtime/python/okstra_ctl/plan_items.py +38 -0
- package/runtime/python/okstra_ctl/run.py +81 -33
- package/runtime/python/okstra_ctl/schema_excerpt.py +5 -3
- package/runtime/python/okstra_ctl/wizard.py +18 -44
- package/runtime/python/okstra_ctl/worker_heartbeat.py +15 -5
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/python/okstra_project/resolver.py +25 -0
- package/runtime/schemas/final-report-v1.0.schema.json +162 -4
- package/runtime/skills/okstra-run/SKILL.md +3 -1
- package/runtime/skills/okstra-setup/SKILL.md +3 -0
- package/runtime/skills/okstra-setup/references/project-config.md +47 -0
- package/runtime/templates/reports/final-report.template.md +51 -0
- package/runtime/templates/reports/i18n/en.json +32 -3
- package/runtime/templates/reports/i18n/ko.json +32 -3
- package/runtime/templates/reports/implementation-input.template.md +1 -2
- package/runtime/templates/reports/task-brief.template.md +1 -1
- package/runtime/validators/validate-brief.py +5 -1
- package/runtime/validators/validate-run.py +430 -48
- package/src/cli-registry.mjs +10 -0
- package/src/commands/execute/phase-cleanup.mjs +38 -0
|
@@ -26,10 +26,10 @@ are collected and convergence finished. Phase 1-5 do not need it.
|
|
|
26
26
|
- **style / lint / type-check results** — each check-only tool the verifier ran, its exit code, and the count of new findings attributable to lines this run introduced. When no tool is configured for a touched language, record the single line `no lint/style tool configured for <language>`,
|
|
27
27
|
- any fix recommendations the verifier declined to apply.
|
|
28
28
|
`Claude lead` synthesises a unified verdict but MUST preserve dissent — do not collapse opinions into one paragraph. External Tier 3 advisory results are excluded from this aggregate promotion and remain user-owned follow-up evidence. If any other verifier issued `FAIL` on a `Discrepancy` line, the synthesised verdict MUST be `FAIL` unless lead cites a concrete reproduction-time reason (committed flaky-test record, documented environment delta) for overriding.
|
|
29
|
-
- **Rollback verification
|
|
29
|
+
- **Rollback verification** (advisory — never blocks): a human-facing record of whether the plan's rollback path is still valid after the changes. A rollback is executed by a human, not by an okstra worker/verifier, so nothing here blocks the run, forces a `contract-violated` outcome, or routes back to planning. Each `rollbackVerification` row's `result` is `ok` (verified), `not-applicable` (nothing to roll back), or `advisory — human-run` (could not verify here; a human owns it). Strength of the record depends on the change category:
|
|
30
30
|
- **Pure code changes** (no persisted state, no infra mutation): a reachable revert SHA is sufficient. Record the exact `git revert <SHA>` command that would undo the change, and confirm `git rev-parse <SHA>` resolves.
|
|
31
|
-
- **Feature-flag-gated changes**:
|
|
32
|
-
- **Schema migrations, config-format changes, or any change with persisted state**:
|
|
31
|
+
- **Feature-flag-gated changes**: prefer confirming the off-switch path was exercised in this run's validation evidence (i.e. one of the validation commands ran with the flag off and succeeded). If the off-path was not exercised here, record the row as `advisory — human-run` rather than treating it as a blocking requirement.
|
|
32
|
+
- **Schema migrations, config-format changes, or any change with persisted state**: record the exact rollback command and, when the migration tool offers a dry-run mode (`--dry-run`, `--plan`, equivalent), its captured exit code / stdout. The rollback itself is a **human-run operation**, not an okstra worker or verifier step — so an unavailable dry-run is documented as such (advisory) and does NOT block the run, does NOT produce a `contract-violated` outcome, and does NOT route back to `implementation-planning`. The deliverable here is recording the revert path clearly enough that a human can act on it.
|
|
33
33
|
- **Manual user test draft**: when this run produces a user-observable change (UI / API / CLI / artifact), write `target / environment / steps / expected result` per change into §5.7.9 (data field `implementation.manualUserTest`, `applicable=true` with `items`). Treat effective `manual-user-test` PREP content only as a seed: reconcile it with the approved plan's `Acceptance:` and this run's actual diff before writing the final steps. Environment line: if the project has a `docker-compose.yml`, use `/okstra-container-build` (which runs `okstra container up <task-id>`) then connect to the published port; otherwise the project's run command (e.g. `npm start`). When there is no user-observable change, set `applicable=false` and give a one-line `exemptionReason` instead of items. These are the steps a human (or `final-verification`) re-runs by hand, NOT the automated validation commands in `Validation evidence`; planning PREP is never a second final-verification manual-test source.
|
|
34
34
|
- **Design preparation handoff**: `implementation.manualUserTest` remains the only manual-test handoff to final-verification. For each effective non-`manual-user-test` item whose `reviewAt.phase` is `final-verification` and whose question remains unresolved after inspecting the actual diff, add one common `missingInformation` row. Set `source` to `design-prep:<PREP-ID>:<assessment-fingerprint>`, put `ifStillOpen` and the unresolved question in `item`, and put the guardrails plus risk in `risk`. Do not introduce an implementation-only PREP schema.
|
|
35
35
|
- **External QA advisory handoff:** for every external Tier 3 result that did
|
|
@@ -31,7 +31,7 @@ Do not scan holistically and stop when it "looks fine". Work the matrix exhausti
|
|
|
31
31
|
- a file that wraps a third-party / library call → `clean-code.md` "Wrapping a third-party call": the wrapper adds behaviour the library does not already provide (read the installed library source before keeping a recovery branch — a `catch` repeating the library's own retry recovers nothing), no comment names a condition the call site does not establish, and a rethrow keeps the original error as `cause`.
|
|
32
32
|
- a file that decides, mutates, or persists state → `clean-code.md` "Mutation and state boundaries": decide on the direct identifier rather than a status/flag proxy, capture before-state in one snapshot ahead of the mutating boundary, update only this work's owned fields on an existing row, re-read state before calling a zero-affected-rows write success or failure, put priority-between-inputs in a named domain function, and keep error messages to what was actually observed. Also check that no state union/enum was re-declared beside an authoritative one the domain or a dependency exports.
|
|
33
33
|
- test file (`*.spec.*` / `*.test.*` / `test_*.py` / `*_test.go` …) → `clean-code.md` "Testing discipline": no self-mocking of the SUT, behavioral (outcome) assertions not interaction-only, no tautological delegation assertion, no effect claimed under its own mock, shared-fixture defaults left on the ordinary path, setup values that actually separate the scenarios, every new test helper/mock used by a test in this same diff, no positional mock-argument access (`rg 'mock\.calls'`), every branch this diff adds covered by a test that fails when the branch body is deleted, assertions on the last write to a record rather than an intermediate one, and test titles naming their unit plus the single condition each case isolates.
|
|
34
|
-
- port / adapter / domain file, when the hexagonal overlay is loaded → `architectures/hexagonal.md`: no business logic in a port body, adapter methods are I/O only (no post-fetch filtering on domain state, no `findValid*`/`findActive*` names hiding a rule), domain objects declared under the domain boundary, no changed domain file importing an ORM / framework / adapter / service (read the import list — mechanical), and a service dependency you add or modify goes through a port rather than a concrete adapter (advisory
|
|
34
|
+
- port / adapter / domain file, when the hexagonal overlay is loaded → `architectures/hexagonal.md`: no business logic in a port body, adapter methods are I/O only (no post-fetch filtering on domain state, no `findValid*`/`findActive*` names hiding a rule), domain objects declared under the domain boundary, no changed domain file importing an ORM / framework / adapter / service (read the import list — mechanical), and a service dependency you add or modify goes through a port rather than a concrete adapter (advisory only while the project has not declared `architecture.style = hexagonal` in `.okstra/project.json` — record it with the port sketch; blocking under that declaration, so fix it in place before the commit rather than record-and-pass, exactly as `_implementation-verifier.md` re-grades this same diff. Either way, the codebase already injecting concrete classes is the debt this pays down, not a reason to skip it).
|
|
35
35
|
A file can hold several roles — apply every rule set that fits it.
|
|
36
36
|
3. **Decide clean-or-finding for each cell.** Read the full file when a rule needs context (never judge a port/adapter/domain or a naming rule from the hunk alone).
|
|
37
37
|
4. **Fix each finding in place** before the commit. When a readability finding is real, the fix is a named helper or named intermediate value — sketch the cleaner shape (a few lines) in your audit note, then apply it. When the fix is genuinely out of this stage's scope, record it as an `Out-of-plan` note instead of silently leaving it.
|
|
@@ -111,6 +111,7 @@ Re-running commands proves the diff *builds and passes*; it does NOT prove the d
|
|
|
111
111
|
- **Scope (no silent sampling).** Enumerate every changed source/test file via `git diff --name-only <base>...HEAD` and review each one. Skipping a changed file silently is a `contract-violated` outcome. If a file's language has no reference and is not covered by the agnostic checks below, record `design-review skipped: <file> (language=<x> no reference)` — never pass it silently.
|
|
112
112
|
- **Load the same conventions the executor used via the routed pack.** Use this worker prompt's `**Coding preflight pack:**` anchor header as the absolute path to the installed routed pack. Read `overview.md` first, then `clean-code.md`, then apply the router's three ordered stages: language, framework, architecture. In each stage, iterate every rule, treat a rule as matched when any listed condition is true, and accumulate every matching resource — including `frameworks/node-server.md` for server-side Node work and `architectures/hexagonal.md` for ports-and-adapters / NestJS-hex layouts. Degrade to the agnostic checks below when the resolved pack is unreadable, and record either `coding-conventions: resources=<...>` or `coding-conventions: resource-unavailable → applied <project rules + agnostic principles>`. The verifier does NOT inline language rules — it loads the same situation-specific resources as the executor preflight.
|
|
113
113
|
- **Load project review rule packs when present.** Search the project root, `.claude/skills`, and up to two parent `skills/` directories for `*review*/SKILL.md` rule packs. Read their referenced `references/*.md` files and apply them as an overlay on this static review. If a premium review skill exists, use its coverage philosophy (recall-first enumeration followed by verify-only confirmation) as the verifier's mental model, but do NOT dispatch extra reviewer agents unless the task explicitly configured them. Record `project-review-rules: <paths read>` or `project-review-rules: none found` in the worker result.
|
|
114
|
+
- **Declared architecture style promotes the placement overlay from advisory to binding.** Read `<PROJECT_ROOT>/.okstra/project.json` — the same file Tier 2's `qaCommands` comes from — take `architecture.style`, and record `architecture-style: <hexagonal|layered|none>` in the worker result next to the `coding-conventions:` line. A declared `hexagonal` counts the overlay as loaded even when none of the router's Stage 3 layout signals matched, so the **Hexagonal** blocking check below applies in full, and the concrete-adapter injection listed under Advisory findings is promoted to a blocking finding → verdict `FAIL`, not a `should-fix`. A declared `layered` has no pack resource; its binding invariant is direction — an upper layer may import a lower one, never the reverse — so a changed file whose import list reaches back up a layer, or around a layer boundary, is a blocking placement violation cited `path:line` from that import list. The `layered` half is worker judgement: no machine check reads layer names, so a missed reverse dependency is a missed finding, not a validator failure. A `none` style, an absent field, or an unreadable `project.json` leaves this section exactly as it is today — Stage 3 stays detection-driven and the placement items stay advisory. **Enforced:** `scripts/okstra_project/resolver.py` `resolve_architecture` reads this same field for the planning-side rule in `validators/validate-run.py` `_validate_variation_point_analysis`, and `_validate_verifier_fail_blocks_verdict` (cited under the DB gate below) keeps the resulting `FAIL` from being dropped during synthesis.
|
|
114
115
|
- **Blocking checks (any hit → verdict `FAIL`, cited `path:line` + rule name, recommended fix recorded — the verifier does NOT apply it):**
|
|
115
116
|
- **New duplication / DRY:** two or more newly added or meaningfully modified blocks implement the same helper stack, transform, or domain rule. Literal copy-paste is always blocking; semantically equivalent transforms across services are blocking unless the approved plan explicitly justified keeping them separate. Recommend the shared module location.
|
|
116
117
|
- **Self-mocking:** a test for `Foo` stubs/spies a method on the `Foo` instance under test (`jest.spyOn(sut, ...)`, `spyOn(FooService.prototype, ...)` in `foo.*.spec.*`, `vi.mocked(sut)` + stub). Mocking injected collaborators is fine.
|
|
@@ -129,7 +130,7 @@ Re-running commands proves the diff *builds and passes*; it does NOT prove the d
|
|
|
129
130
|
- **Positional mock-argument access:** `mock.calls[<n>][<m>]` (or the framework equivalent) used to inspect arguments instead of an intent-revealing `toHaveBeenCalledWith` / explicit-absence assertion. Detect with `rg 'mock\.calls'` over the changed test files.
|
|
130
131
|
- **Non-separating test data:** two scenarios whose setup values and assertions are identical, so a wrong implementation passes both; or new test tooling added in this diff (mock, state setter, repository branch) that no test calls.
|
|
131
132
|
- **Effect claimed under its own mock:** a test presented as covering an effect whose producing path is replaced by a mock inside that same test. The mock's presence in the harness is not evidence the branch behind it works.
|
|
132
|
-
- **Advisory findings (recorded as recommendations; verdict MAY still PASS):** function >50 effective lines, a single body mixing read+write stages, weak readability, a missing-but-non-critical outcome assertion, newly orphaned private/public code that is safe to remove but not on a critical path, weak-but-not-misleading names, priority-between-inputs policy inlined in a service condition instead of a named domain function, an error message asserting a cause the code never observed, a memory / concurrency / batching change with no test pinning the bound it claims, or (hexagonal overlay only) a service dependency this diff adds or modifies that injects a concrete adapter instead of a port — record it with the port sketch; an existing convention of concrete injections does not convert this one to `clean`, it is the debt the rule pays down. These land in the verifier result as `should-fix` / `nit` recommendations, not as a `FAIL`.
|
|
133
|
+
- **Advisory findings (recorded as recommendations; verdict MAY still PASS):** function >50 effective lines, a single body mixing read+write stages, weak readability, a missing-but-non-critical outcome assertion, newly orphaned private/public code that is safe to remove but not on a critical path, weak-but-not-misleading names, priority-between-inputs policy inlined in a service condition instead of a named domain function, an error message asserting a cause the code never observed, a memory / concurrency / batching change with no test pinning the bound it claims, or (hexagonal overlay only) a service dependency this diff adds or modifies that injects a concrete adapter instead of a port — record it with the port sketch; advisory only while the project has not declared `architecture.style = hexagonal`, since that declaration — and only that one — promotes exactly this item to blocking per the declared-style bullet above, while a declared `layered` binds dependency direction instead and leaves this item advisory; an existing convention of concrete injections does not convert this one to `clean`, it is the debt the rule pays down. These land in the verifier result as `should-fix` / `nit` recommendations, not as a `FAIL`.
|
|
133
134
|
- **Output.** Every finding — blocking or advisory — is a structured item in the verifier's worker result (`path:line`, rule, severity, suggested fix) so it carries into Phase 5.5 convergence and the final report. A blocking hit sets the verifier verdict to `FAIL` with the rule cited, using the same verdict machinery as the Discrepancy rule above. `Claude lead` MUST NOT silently downgrade a cited blocking finding to advisory during synthesis; an override requires a concrete cited reason, exactly as for the Discrepancy rule.
|
|
134
135
|
|
|
135
136
|
### Fix-run incremental scope (applies when the profile carries a "Fix-Run Carry" block)
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
- **Graph-aware scope:** a graph edge can explain ordering or duplication, but it is not proof of cause by itself. Cite code/log evidence before claiming an upstream related task caused the current symptom.
|
|
23
23
|
- **Sharp next diagnostic:** end with the single highest-value diagnostic command, log capture, or file inspection that should happen next, plus the expected signal that would confirm or reject the leading cause.
|
|
24
24
|
- **Fix-design boundary:** do not design the implementation fix beyond what is necessary to validate the cause. If the cause is credible, route to `implementation-planning` with the verified evidence; if the cause is still unclear, route to another `error-analysis` run with the next diagnostic.
|
|
25
|
+
- Structured diagnosis and routing contract:
|
|
26
|
+
- `errorAnalysis` is the source of truth for reproduction status, `EA-NNN` cause candidates, the sharp next diagnostic, and the next route.
|
|
27
|
+
- A route to `implementation-planning` requires a credible leading cause referenced by `routing.leadingCauseId` and `begin-planning` as the direction. A route back to `error-analysis` requires the sharp next diagnostic and `continue-investigation` as the direction.
|
|
28
|
+
- Structure is enforced by `schemas/final-report-v1.0.schema.json` `$defs.ErrorAnalysis`. Cross-field diagnosis and route semantics are enforced by `validators/validate-run.py::_validate_error_analysis_consistency`.
|
|
25
29
|
- Primary focus areas:
|
|
26
30
|
- symptom and trigger clarification
|
|
27
31
|
- root-cause candidates
|
|
@@ -40,7 +44,7 @@
|
|
|
40
44
|
- **Codebase-first ambiguity resolution (defect rule)**: any ambiguity about repro, file behavior, or symbol semantics that can be answered by `Read` / `Grep` / log inspection MUST be resolved that way and recorded with file:line (or log-line) evidence. Writing a clarification row for something the codebase or shipped logs already answer is a defect of this phase.
|
|
41
45
|
- **Evidence note required inside `Statement`**: every clarification row includes `Evidence checked: <path:line>` or `Evidence checked: none — <reporter-only reason>` in the `Statement` cell. `none` is allowed ONLY when the row's nature is "only the reporter can answer this" (reporter-side data, business priority, environment they observed). A row with `none` that *could* have been answered by code or logs is a defect.
|
|
42
46
|
- Cross-verification mode:
|
|
43
|
-
- Phase 5.5 convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each root-cause / reproduction claim by directly re-inspecting the cited code, logs, or config; the burden of proof sits on the claim. See `prompts/lead/convergence.md` §"Adversarial Verification Mode". A single evidence-backed refutation prevents a finding from reaching consensus.
|
|
47
|
+
- Phase 5.5 convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each root-cause / reproduction claim by directly re-inspecting the cited code, logs, or config; the burden of proof sits on the claim. See `prompts/lead/convergence.md` §"Adversarial Verification Mode". Multi-source findings enter the adversarial queue rather than becoming automatic consensus. A single evidence-backed refutation prevents a finding from reaching consensus, remains in the round history, and cannot be erased into full consensus by later agreement.
|
|
44
48
|
{{INCLUDE:_coverage-critic.md}}
|
|
45
49
|
- Non-goals:
|
|
46
50
|
- implementation details unless they are necessary to validate the cause
|
|
@@ -40,7 +40,6 @@
|
|
|
40
40
|
"silent scope expansion: every file edited outside the approved plan list MUST appear in the `Out-of-plan edits` block with rationale",
|
|
41
41
|
"leaving placeholders such as TBD / TODO / \"implement later\" / \"handle edge cases\" in newly-added lines of this run (check via `git diff <base>..HEAD | grep -E '^\\+[^+].*\\b(TBD|TODO|FIXME|XXX|implement later|handle edge cases|similar to|placeholder)\\b'`; pre-existing strings in untouched regions are out of scope)",
|
|
42
42
|
"lead substituting its own verdict when every verifier present in the resolved roster (`Claude verifier`, `Codex verifier`, plus `Antigravity verifier` when opted in) returned a non-result terminal status (`timeout`/`error`/`not-run`); in that case the run MUST end as `blocked` with routing recommendation back to `error-analysis`, never with a lead-only verdict",
|
|
43
|
-
"claiming rollback verification on a schema migration, config-format change, or any persisted-state mutation without a recorded dry-run of the rollback step and its captured exit code",
|
|
44
43
|
"declaring overall task acceptance — that is `final-verification` ownership; this phase reports only \"ready for final-verification\" or \"needs new planning loop\"",
|
|
45
44
|
"delegating the self-review pass to a generic subagent — `Claude lead` must run it"
|
|
46
45
|
],
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
- read the task brief, related-task briefs, and any cited spec / design doc end-to-end
|
|
23
23
|
- inspect the current state of every file the task names (or the closest matching files if names are stale) — record current responsibilities, public interfaces, and known coupling points
|
|
24
24
|
- skim recent commits touching those files (`git log -- <path>`) to surface in-flight work or contested areas
|
|
25
|
+
- **sibling exploration (variation-point evidence)**: read the `Related Task Graph` sibling / `related-to` tasks' done artifacts *and the code they actually landed* — a done report is a claim, the diff is the fact. When a sibling already implements the same behavior for another resource, that is a variation point: register the existing implementation in `variationPointAnalysis.evidence` and weigh an option that refactors it behind a shared interface instead of adding a second parallel implementation alongside it. Absent an explicit graph edge, still surface a same-behavior implementation you saw in the files or `git log` output already inspected above — an unrecorded edge does not make the duplication less real.
|
|
25
26
|
- **codebase-first ambiguity resolution**: any ambiguity that can be answered by `Read` / `Grep` MUST be resolved that way and recorded with file:line evidence. Only ambiguities that genuinely require a human decision are escalated as `Clarification Items` rows. Writing a clarification row for something the code already answers is a defect of this phase.
|
|
26
27
|
- flag any requirement that is ambiguous, contradictory, or missing success criteria — register each one as a row in the report's `## 1. Clarification Items` table with `Blocks=approval` instead of guessing
|
|
27
28
|
- read `<PROJECT_ROOT>/.okstra/glossary.md` and `<PROJECT_ROOT>/.okstra/decisions/` titles if present. Absent okstra memory files are the normal state — do not error. Treat the brief's `terminology:*` resolutions from `requirements-discovery` (if any) as authoritative; if missing, resolve any remaining fuzzy term as a `Blocks=approval` clarification row.
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
- **Isolation & single responsibility**: each unit touched should have one clear purpose, well-defined interface, and be independently testable. Penalize options that widen a unit's responsibility.
|
|
38
39
|
- **Files that change together live together**: split by responsibility, not by technical layer. Penalize options that scatter one logical change across unrelated layers.
|
|
39
40
|
- **Follow established patterns**: in existing codebases, conform to current conventions. Targeted cleanup of a file you are already modifying is acceptable; unrelated refactors are not.
|
|
41
|
+
- **Variation-point extraction (OCP)**: when the same behavior is served by two or more resources / implementations — stated in the brief, or foreseeable from a sibling task or the code you inspected — the plan MUST record it in `variationPointAnalysis` and include an option that extracts the variation point behind an interface (a port, or a strategy the next implementation plugs into), scored against the non-extracted option in the trade-off matrix. Penalize an option that branches on resource identity inside a service (one `if` / `switch` arm per implementation): adding the next implementation then means editing that same call site again, which is the closed-for-extension shape this principle exists to catch. This does not contradict YAGNI below: YAGNI drops *speculative* variation (a second implementation nobody named), while a behavior with two implementations already on the table is a present fact, not a forecast. **Enforced:** the `variationPointAnalysis` bullet under `Required deliverable shape` names the schema / validator / `P-Var-*` enforcement points.
|
|
40
42
|
- **YAGNI ruthlessly**: drop features, abstractions, and configuration knobs that do not serve the stated requirement.
|
|
41
43
|
- **Project review-rule preflight**: before choosing the recommended option, look for project-local review rule packs such as `<PROJECT_ROOT>/skills/*review*`, `<PROJECT_ROOT>/.claude/skills/*review*`, and up to two parent directories' `skills/*review*/SKILL.md`. If present, read the relevant `SKILL.md` plus referenced `references/*.md` files and treat their rules as planning constraints. Do not run the PR-review workflow here; extract only the rules. For Fonts Ninja-style TS/NestJS review packs, this means planning away known review findings before code exists: shared transforms instead of duplicate helper stacks, behavioral tests instead of collaborator-tautology assertions, domain rules in domain modules rather than repositories/adapters, domain objects under `domain/`, plain-English functions, truthful/specific names, and no dead APIs introduced by the plan.
|
|
42
44
|
- Expected output emphasis:
|
|
@@ -57,7 +59,7 @@
|
|
|
57
59
|
- The YAML frontmatter `approved: true|false` field is the only authorised approval gate. report-writer always emits `approved: false`. The user clears it either by (a) editing the frontmatter line to `approved: true` directly, or (b) invoking the next phase with `--approve` so the CLI flips the frontmatter on the user's behalf. `okstra_ctl.run._validate_approved_plan` reads this field and refuses entry until it is `true`.
|
|
58
60
|
- Cross-verification mode:
|
|
59
61
|
- Phase 5.5 finding convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each worker finding (requirement gap / risk / option) by re-inspecting its cited evidence; the burden of proof sits on the claim. See `prompts/lead/convergence.md` §"Adversarial Verification Mode".
|
|
60
|
-
- §5.5.9 plan-body verification runs with an **adversarial posture** (`prompts/lead/plan-body-verification.md` §"Adversarial plan-body posture"): verifiers open and confirm every cited path / command and put the burden of proof on the plan. The gate threshold is majority-based for kinds `b`/`c`/`e`, but a single `DISAGREE` blocks on its own for the concrete, safety-critical
|
|
62
|
+
- §5.5.9 plan-body verification runs with an **adversarial posture** (`prompts/lead/plan-body-verification.md` §"Adversarial plan-body posture"): verifiers open and confirm every cited path / command and put the burden of proof on the plan. The gate threshold is majority-based for kinds `b`/`c`/`e`, but a single `DISAGREE` blocks on its own for the concrete, safety-critical kind `a` (path/symbol mismatch) — and `f` on `P-Req-*` items. `P-Var-*` items are excepted from the kind-`a` exception: a variation-point defect takes a majority. Rollback ordering (`d`) is advisory and never blocks the gate — a rollback is executed by a human, not by okstra's workers or verifiers. A majority also needs ≥2 participating votes, so a lone dissent whose peer returned a non-result does not block on a majority-gated kind (see that contract's §"Adversarial plan-body posture").
|
|
61
63
|
- **Incremental re-verification scope (clarification re-runs):** when the lead's `okstra incremental-scope` decision is `mode == "incremental"` (procedure in `prompts/launch.template.md` §"Clarification Response Carried In"), workers re-analyze ONLY the stages listed in `reverify_stages` (the downstream closure of the impacted stages). Workers MUST NOT re-open, re-score, or re-judge any stage in `carry_stages` — those stages' prior plan-item verdicts are carried forward verbatim, and a worker never overwrites a carried verdict with its own judgement. When the decision is `mode == "full"` (the default), every stage is re-analyzed as usual.
|
|
62
64
|
- **Single incremental-scope decision:** the lead calls `okstra incremental-scope` exactly once for the re-run, passing the answered `C-NNN` ids through `--answered-clarifications`, changed design-preparation IDs through `--prep-items`, and any lead-resolved stage numbers through `--impacted`; the CLI unions all three before applying the existing dependency closure and cutoff. The clarification ids are resolved to stages by the CLI from the prior report's own `planItems[].clarificationId` and `blocked C-NNN` coverage links — the lead does not map answers to stage numbers. An answer that changes the selected option, Stage Map, or recommended approach is not a local impact: pass every CSV empty so the same call returns `mode == "full"`. A clarification id that traces to no stage, unknown PREP IDs, or invalid `stageRefs` also return an explicit full decision instead of being guessed.
|
|
63
65
|
- **Stage-aware carry:** for an incremental decision, pass its `carry_stages` and `reverify_stages` CSVs unchanged to `okstra incremental-carry`. The helper carries the prior whole stage rows and their owned PREP / `P-Prep-*` artifacts; overlap, cross-scope ownership, scope leaks, or canonical conflicts return `CarryError`. On that error, discard the partial merge and run full re-verification.
|
|
@@ -93,6 +95,8 @@
|
|
|
93
95
|
- estimated blast radius (units, configs, deployment manifests, data migrations)
|
|
94
96
|
- trade-off matrix across options (rows = options, columns at minimum: complexity, risk, reversibility, test coverage cost, rollout cost)
|
|
95
97
|
- recommended option with rationale tied to the design principles above
|
|
98
|
+
- **Variation-point analysis (`variationPointAnalysis`, mandatory — every plan emits the block, rendered as §5.5.11):** declare `hasMultipleImplementations`, and when it is `true`, one `points[]` row per varying behavior carrying `behavior`, the two or more `implementations` that serve it, `evidence` (a `path:line`, or the sibling task / stage that already implements it), and an `extractionDecision` of `extract` / `interfaceKind` / `coveredBy` (the Stage Map stage that builds the interface) / `rationale`. **A `false` declaration is not an omission — it is a claim**, so it carries a written `noVariationRationale` and an empty `points` array; the two are mutually exclusive, because declared points would be silently dropped from verification under a `false` header. A project whose `.okstra/project.json` sets `architecture.style: hexagonal` extracts a point as a port (`interfaceKind: "port"`), never as a shared helper. **Enforced:** `schemas/final-report-v1.0.schema.json` `$defs.VariationPointAnalysis` / `$defs.VariationPoint` make the block required and pin the row shape; `validators/validate-run.py` `_validate_variation_point_analysis` rejects a `false` declaration with no rationale, a `false` declaration carrying points, a `true` declaration with no point, an `extract: true` decision naming no `interfaceKind` or no `coveredBy`, and a hexagonal project extracting as anything but a port; and every point becomes a `P-Var-<N>` plan item judged in §5.5.9 (`prompts/lead/plan-body-verification.md`) — a plan declaring no variation point is still verified, through the lone `P-Var-0`.
|
|
99
|
+
- **Test seams (`recommendedOption.testSeams`, mandatory array):** one row per boundary a test injects at and replaces — `boundary` (what the seam sits on), `injectedAs` (the concrete construction / wiring point a test substitutes at), `replacedInTest` (what the test puts there instead). An **empty array is legal but is itself a claim**: "this plan needs no seam." Its absence is what forces self-mocks — with no declared injection point the implementation ends up mocking the unit against its own re-implementation, and those tests pass regardless of whether the behavior is right. A row whose `injectedAs` names no construction or wiring point a test can actually replace is a seam on paper only. **Enforced:** `schemas/final-report-v1.0.schema.json` makes `testSeams` required on `recommendedOption` and requires all three row fields; the §5.5.9 `P-Var-*` round DISAGREEs when a declared seam (or an `extractionDecision.coveredBy`) is not actually injectable.
|
|
96
100
|
- **Working Assumptions (non-blocking)**: inside the `Recommended Option` section, an `Assumptions:` labelled list recording each assumption the plan proceeds on **without** user confirmation but that does NOT block approval — a sensible default the reviewer can still veto (e.g. "assuming the existing retry policy stays; not re-tuning it here"). This is distinct from `## 1. Clarification Items`, which gates approval: anything that must be answered before coding is a clarification row, never an assumption. Omit the list only when there are genuinely none. (No new scanned heading — it lives under the existing `Recommended Option`. The `Authority & permissions` class from the shared contract is explicitly NOT recorded here — that class is suppressed, not surfaced.)
|
|
97
101
|
- **Stage Map (mandatory — always emitted, even when N=1):** a table of all stages with `stage | title | depends-on | step-count | exit-contract-summary`. `depends-on` is `(none)` or a comma-separated stage number list. Stages with `depends-on (none)` can be implemented in parallel by two simultaneous `implementation` runs.
|
|
98
102
|
- **Keep the table at exactly 5 columns** — do NOT add a column. `validators/validate-implementation-plan-stages.py` parses `stage | title | depends-on | step-count | exit-contract-summary` and silently skips any row that is not exactly 5 cells, so a 6th column would drop every stage and bypass S2–S11.
|
|
@@ -163,7 +167,7 @@
|
|
|
163
167
|
```
|
|
164
168
|
|
|
165
169
|
An `AGREE` note records the counterexample considered and its exclusion reason. If the judgement needs unavailable external material, record `verification-error`, not `DISAGREE`. **Enforced:** `validators/validate-run.py` `_validate_plan_item_extraction_completeness` compares the exact deterministic set, independently rejecting missing, unexpected, and duplicate plan-item IDs, including `P-Prep-*`.
|
|
166
|
-
- **§5.5.9 Plan Body Verification (BLOCKING).** After report-writer finishes the draft, the lead MUST run a worker peer-review round on the consolidated plan body (Option Candidates / Trade-off Matrix / Recommended Option / Stage Map and per-stage sections / Dependency / Validation Checklist / Rollback / Requirement Coverage) and populate `### 5.5.9 Plan Body Verification` in the final report. The round protocol, plan-item ID scheme (`P-Opt-*` / `P-Step-*` / `P-Dep-*` / `P-Val-*` / `P-Rb-*`), verdict semantics, gate-result classification, and dissent log format are defined in `prompts/lead/plan-body-verification.md`. The four gate-result values are `passed`, `passed-with-dissent`, `blocked-by-disagreement`, `aborted-non-result`. When the gate would have been `blocked-by-disagreement` or `aborted-non-result`, the lead MUST NOT silently flip it to one of the passing values to "unblock" the run — that is a contract violation. **Enforced:** `validators/validate-run.py` `_validate_plan_body_gate_recompute` re-derives the gate from `planItems[].verdicts` and fails when the declared `gateResult` claims a healthier outcome than the recorded votes support; `_validate_plan_item_extraction_completeness` fails when any plan-body deliverable category is under-extracted into `planItems`, so a dropped item can no longer dodge the gate. When `convergence.adversarial=true` (the default for this phase), this round uses the adversarial posture — verifiers confirm cited paths/commands and the burden of proof is on the plan — but the gate threshold stays `majority-disagree` (see that skill's §"Adversarial plan-body posture"). Among the majority-disagree items, those that are majority-`planner-fixable` go through report-writer self-fix rounds (`prompts/lead/plan-body-verification.md` "Self-fix round"). A `planner-fixable` item that survives the budget is **not** promoted to the user — it becomes a Working Assumption in `## 5. Missing Information and Risks` and folds into `passed-with-dissent`, because a defect the planner could have fixed is not a user decision. Only majority-`needs-user-input` items, and correctness-critical defects (`DISAGREE`
|
|
170
|
+
- **§5.5.9 Plan Body Verification (BLOCKING).** After report-writer finishes the draft, the lead MUST run a worker peer-review round on the consolidated plan body (Option Candidates / Trade-off Matrix / Recommended Option / Stage Map and per-stage sections / Dependency / Validation Checklist / Rollback / Requirement Coverage) and populate `### 5.5.9 Plan Body Verification` in the final report. The round protocol, plan-item ID scheme (`P-Opt-*` / `P-Step-*` / `P-Dep-*` / `P-Val-*` / `P-Rb-*`), verdict semantics, gate-result classification, and dissent log format are defined in `prompts/lead/plan-body-verification.md`. The four gate-result values are `passed`, `passed-with-dissent`, `blocked-by-disagreement`, `aborted-non-result`. When the gate would have been `blocked-by-disagreement` or `aborted-non-result`, the lead MUST NOT silently flip it to one of the passing values to "unblock" the run — that is a contract violation. **Enforced:** `validators/validate-run.py` `_validate_plan_body_gate_recompute` re-derives the gate from `planItems[].verdicts` and fails when the declared `gateResult` claims a healthier outcome than the recorded votes support; `_validate_plan_item_extraction_completeness` fails when any plan-body deliverable category is under-extracted into `planItems`, so a dropped item can no longer dodge the gate. When `convergence.adversarial=true` (the default for this phase), this round uses the adversarial posture — verifiers confirm cited paths/commands and the burden of proof is on the plan — but the gate threshold stays `majority-disagree` (see that skill's §"Adversarial plan-body posture"). Among the majority-disagree items, those that are majority-`planner-fixable` go through report-writer self-fix rounds (`prompts/lead/plan-body-verification.md` "Self-fix round"). A `planner-fixable` item that survives the budget is **not** promoted to the user — it becomes a Working Assumption in `## 5. Missing Information and Risks` and folds into `passed-with-dissent`, because a defect the planner could have fixed is not a user decision. Only majority-`needs-user-input` items, and correctness-critical defects (`DISAGREE` kind `a`, or `f` on `P-Req-*`) regardless of fixability, become `Blocks=approval` clarifications. Rollback ordering (`d`) is advisory and never blocks approval — a rollback is executed by a human. `validators/validate-run.py` `_validate_self_fix_before_clarification` fails a planner-fixable majority item promoted without a self-fix as `contract-violated`.
|
|
167
171
|
- **Decision-record evaluation (sole owner)**: this phase is the **single owner** of decision-record evaluation in the okstra lifecycle. The brief never evaluates or drafts decision records — it only forwards `adr-candidate:*` signals. Every `adr-candidate:*` entry inherited from the brief's `Open Questions` is a mandatory evaluation target. In addition, evaluate every decision the recommended option introduces against the three criteria:
|
|
168
172
|
1. **Hard to reverse** — would changing the decision later cost meaningfully more than deciding now?
|
|
169
173
|
2. **Surprising without context** — would a future reader, seeing only the code, wonder "why was it built this way?"?
|
|
@@ -187,3 +191,4 @@
|
|
|
187
191
|
8. **Stage Map self-check** — for every stage, count the effective rows of its `Stepwise Execution Order` table by hand; reject the draft if any stage exceeds 8. Confirm each stage declares a non-empty `Slice value:` and `Acceptance:` line, the three `Test case (success|boundary|failure):` lines (or carries a `TDD exemption:` line), and that its first step `action` starts with `RED:` with a later `GREEN:` — this is what validator S10 enforces, including S10d on the test-case lines. Read each stage's three test-case lines as a reviewer: reject any that restates the happy path in all three slots, leaves `boundary` blank, or writes `N/A` where a real edge input exists. Walk the `depends-on` graph and confirm it is a DAG (no cycle, no self-reference). For each `depends-on` link, confirm it encodes a real data/contract dependency — do NOT add links to serialise unrelated work, and do NOT split a stage merely to create more parallel stages. **Parallel-safety:** for every pair of `depends-on (none)` stages, confirm their `Stage Exit Contract` predicted file sets are disjoint; if they share a file, merge them or add a `depends-on` link (validator S9 rejects overlap). **Project-boundary:** confirm no stage mixes edits from two projects (different repo/`PROJECT_ROOT` or different top-level deployable module); if any stage does, split it per project. For multi-project plans, confirm each stage's `title` carries its `[<project>]` tag and the `Cross-project parallelism:` line under the table records the parallel-vs-sequenced determination (with the forcing dependency) for every project pair; for cross-repo work, confirm it is split into separate per-repo runs (required — one run structurally cannot touch another repo) rather than crammed into one task's stages.
|
|
188
192
|
9. **Cross-project dependency check** — confirm you have not missed a dependency on another repo / another top-level deployable module / a published package. If `dependencyMigrationRisk` has a `kind: cross-project` row, confirm a matching `direction: upstream-precondition` `XP-NNN` row exists in `crossProjectDependencies`, and re-read as a reviewer whether its `requiredWork` is the concrete work the other side must actually build rather than an abstract phrase ("other side's work done") — validator S only checks existence, so concreteness is the self-review's responsibility. Confirm cross-repo work is split into a separate run + XP row instead of being crammed into one task's stages, and that the cross-project substance is not duplicated in `§3 Recommended Next Steps` but lives only in `§5.4 Cross-Project Dependencies`.
|
|
189
193
|
10. **Decision-draft materialization check** — when `decisionDrafts` is non-empty, confirm as a reviewer which stage's stepwise order contains the matching materialization step (creating `.okstra/decisions/<NNNN>-<slug>.md`) and that the number of drafts corresponds 1:1 with the materialization steps. The validator only checks the *existence* of the step, so the `<NNNN>-<slug>` correctness and count correspondence are the self-review's responsibility.
|
|
194
|
+
11. **Variation-point & seam check** — read `variationPointAnalysis` as a skeptic. Is `hasMultipleImplementations` honest against the brief and the sibling code you inspected during pre-planning, or was `false` chosen because it is the cheaper field to fill? For every point with `extract: true`, confirm the `extractionDecision` names a real interface (a `port` for a hexagonal project, not a shared helper) and a `coveredBy` stage that exists in the Stage Map — an interface no stage builds is a decision nobody executes. Then read the recommended option's `testSeams`: each `injectedAs` must name a construction or wiring point a test can actually substitute at, not a symbol the test would have to re-implement — a seam nothing can be injected into leaves the executor writing self-mocks. An empty `testSeams` array is only acceptable when you can defend it in one sentence; the validator accepts it either way, so this is the check that catches an unfilled field posing as a decision.
|
|
@@ -43,6 +43,13 @@
|
|
|
43
43
|
- an ordering edge seeded from the brief's `Related Task Graph` is preserved in each packet's `depends-on`
|
|
44
44
|
and in the final report's routing rationale. A `duplicates` / `related-to` edge is
|
|
45
45
|
not interpreted as fan-out ordering; use it only as a reference for avoiding duplicate work / scope alignment.
|
|
46
|
+
One exception to "only a reference": when such an edge — `related-to` / `duplicates` / `split-from`, or a
|
|
47
|
+
shared `parent-of` parent — names a sibling task that performs the same behaviour on a different resource
|
|
48
|
+
(same action, different noun), record that commonality in the final report's routing rationale as a
|
|
49
|
+
variation-point input: a named candidate for shared-interface extraction that the next
|
|
50
|
+
`implementation-planning` run reads when it fills `variationPointAnalysis`. This does not fan out and adds
|
|
51
|
+
no `depends-on` edge — it stays a routing-rationale note. Without it the planner meets the task alone and
|
|
52
|
+
scores its options against a single implementation.
|
|
46
53
|
- in the final report, do not duplicate the decomposition result; keep only the one-line "fan-out: N packets → fan-out/index.md"
|
|
47
54
|
pointer. Packet execution is separate: the user starts each unit as a new task-key via
|
|
48
55
|
`okstra-run --task-brief <packet path>` (this phase does not directly start any downstream run).
|
|
@@ -20,6 +20,27 @@ BRIEF_SECTIONS = (
|
|
|
20
20
|
"Task Continuity Notes",
|
|
21
21
|
"Available MCP Servers",
|
|
22
22
|
)
|
|
23
|
+
ERROR_ANALYSIS_BRIEF_SECTIONS = (
|
|
24
|
+
"Source Material",
|
|
25
|
+
"Context",
|
|
26
|
+
"Problem / Symptom",
|
|
27
|
+
"Desired Outcome",
|
|
28
|
+
"Expected Behavior",
|
|
29
|
+
"Preserved Behavior",
|
|
30
|
+
"Expected Outcome",
|
|
31
|
+
"External Gates",
|
|
32
|
+
"Constraints",
|
|
33
|
+
"Scan Scope",
|
|
34
|
+
"Priority Lenses",
|
|
35
|
+
"Related Artifacts",
|
|
36
|
+
"Related Task Graph",
|
|
37
|
+
"Open Questions",
|
|
38
|
+
"Reporter Confirmations",
|
|
39
|
+
"Augmentation",
|
|
40
|
+
) + BRIEF_SECTIONS
|
|
41
|
+
BRIEF_SECTIONS_BY_TASK_TYPE = {
|
|
42
|
+
"error-analysis": ERROR_ANALYSIS_BRIEF_SECTIONS,
|
|
43
|
+
}
|
|
23
44
|
PROFILE_SECTIONS = (
|
|
24
45
|
"Primary focus areas",
|
|
25
46
|
"Expected output emphasis",
|
|
@@ -30,6 +51,7 @@ WORKER_PROFILE_SECTIONS_BY_TASK_TYPE = {
|
|
|
30
51
|
"Worker discovery procedure",
|
|
31
52
|
),
|
|
32
53
|
"error-analysis": (
|
|
54
|
+
"Brief consumption",
|
|
33
55
|
"Worker diagnosis procedure",
|
|
34
56
|
),
|
|
35
57
|
"implementation-planning": (
|
|
@@ -79,7 +101,7 @@ def build_analysis_packet(
|
|
|
79
101
|
bool(clarification_response_path),
|
|
80
102
|
)
|
|
81
103
|
)
|
|
82
|
-
parts.extend(_brief_block(brief_text))
|
|
104
|
+
parts.extend(_brief_block(task_type, brief_text))
|
|
83
105
|
parts.extend(_profile_block(task_type, profile_text))
|
|
84
106
|
parts.extend(_reference_block(reference_text))
|
|
85
107
|
parts.extend(_fix_history_block(fix_history_text))
|
|
@@ -138,12 +160,15 @@ def _intro_block(
|
|
|
138
160
|
return lines
|
|
139
161
|
|
|
140
162
|
|
|
141
|
-
def _brief_block(brief_text: str) -> list[str]:
|
|
163
|
+
def _brief_block(task_type: str, brief_text: str) -> list[str]:
|
|
142
164
|
return [
|
|
143
165
|
"",
|
|
144
166
|
"## Task-Specific Brief Extract",
|
|
145
167
|
"",
|
|
146
|
-
_extract_sections(
|
|
168
|
+
_extract_sections(
|
|
169
|
+
brief_text,
|
|
170
|
+
BRIEF_SECTIONS_BY_TASK_TYPE.get(task_type, BRIEF_SECTIONS),
|
|
171
|
+
),
|
|
147
172
|
]
|
|
148
173
|
|
|
149
174
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared lightweight parser for brief markdown frontmatter."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_BRIEF_FRONTMATTER_LINE_RE = re.compile(r"^([a-zA-Z0-9_\-]+)\s*:\s*(.*)$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_brief_frontmatter(path: Path) -> dict[str, str]:
|
|
13
|
+
"""Read a brief's YAML-style frontmatter into a flat key-value map.
|
|
14
|
+
|
|
15
|
+
Returns ``{}`` if the file is unreadable, has no frontmatter, or the
|
|
16
|
+
frontmatter is malformed. Comments and quoted values are stripped.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
text = path.read_text(encoding="utf-8")
|
|
20
|
+
except OSError:
|
|
21
|
+
return {}
|
|
22
|
+
if not text.startswith("---"):
|
|
23
|
+
return {}
|
|
24
|
+
lines = text.splitlines()
|
|
25
|
+
if not lines or lines[0].strip() != "---":
|
|
26
|
+
return {}
|
|
27
|
+
out: dict[str, str] = {}
|
|
28
|
+
for line in lines[1:]:
|
|
29
|
+
if line.strip() == "---":
|
|
30
|
+
break
|
|
31
|
+
comment_idx = line.find("#")
|
|
32
|
+
if comment_idx >= 0:
|
|
33
|
+
line = line[:comment_idx]
|
|
34
|
+
match = _BRIEF_FRONTMATTER_LINE_RE.match(line.strip())
|
|
35
|
+
if not match:
|
|
36
|
+
continue
|
|
37
|
+
key, value = match.group(1), match.group(2).strip()
|
|
38
|
+
if (
|
|
39
|
+
len(value) >= 2
|
|
40
|
+
and value[0] == value[-1]
|
|
41
|
+
and value[0] in ("'", '"')
|
|
42
|
+
):
|
|
43
|
+
value = value[1:-1]
|
|
44
|
+
out[key] = value
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_canonical_generated_brief(frontmatter: Mapping[str, str]) -> bool:
|
|
49
|
+
return (
|
|
50
|
+
frontmatter.get("type") == "brief"
|
|
51
|
+
and frontmatter.get("generator") == "okstra-brief-gen"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def has_reporter_confirmation_contract(frontmatter: Mapping[str, str]) -> bool:
|
|
56
|
+
return "reporter-confirmations" in frontmatter
|
|
@@ -27,7 +27,7 @@ from dataclasses import dataclass
|
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
from typing import Optional
|
|
29
29
|
|
|
30
|
-
from okstra_ctl.md_table import is_separator_row, split_pipe_row
|
|
30
|
+
from okstra_ctl.md_table import is_separator_row, split_pipe_row, to_cell_text
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
# The final-report renderer (render_final_report.py:_inject_anchors) appends a
|
|
@@ -372,6 +372,24 @@ def user_response_sidecars(source: Path) -> list[Path]:
|
|
|
372
372
|
)
|
|
373
373
|
|
|
374
374
|
|
|
375
|
+
def _sidecar_answers(source: Path) -> dict[str, str]:
|
|
376
|
+
"""`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
|
|
377
|
+
|
|
378
|
+
같은 id 가 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다. `value`
|
|
379
|
+
가 빈 항목은 담지 않는다. `user_response` 를 지연 import 해 순환 참조를 피한다.
|
|
380
|
+
"""
|
|
381
|
+
from okstra_ctl.user_response import parse_user_response_entries
|
|
382
|
+
|
|
383
|
+
answers: dict[str, str] = {}
|
|
384
|
+
for sidecar in user_response_sidecars(source):
|
|
385
|
+
for entry in parse_user_response_entries(
|
|
386
|
+
sidecar.read_text(encoding="utf-8")
|
|
387
|
+
):
|
|
388
|
+
if entry.value:
|
|
389
|
+
answers[entry.response_id] = entry.value
|
|
390
|
+
return answers
|
|
391
|
+
|
|
392
|
+
|
|
375
393
|
def attached_user_responses_section(source: Path) -> str:
|
|
376
394
|
"""`source` 형제 `user-responses/` 사이드카만 모은 `# Attached User Responses`
|
|
377
395
|
섹션 본문. 사이드카 부재 시 빈 문자열.
|
|
@@ -410,19 +428,29 @@ def clarification_response_with_sidecars(source: Path) -> str:
|
|
|
410
428
|
"""
|
|
411
429
|
text = source.read_text(encoding="utf-8")
|
|
412
430
|
section = attached_user_responses_section(source)
|
|
413
|
-
|
|
431
|
+
answers = _sidecar_answers(source)
|
|
432
|
+
body = _clarification_carry_body(source, text, answers)
|
|
414
433
|
if not section:
|
|
415
434
|
return body
|
|
416
435
|
return body.rstrip("\n") + "\n\n---\n\n" + section
|
|
417
436
|
|
|
418
437
|
|
|
419
|
-
def _clarification_carry_body(
|
|
420
|
-
|
|
438
|
+
def _clarification_carry_body(
|
|
439
|
+
source: Path, text: str, answers: dict[str, str]
|
|
440
|
+
) -> str:
|
|
441
|
+
"""final-report 소스는 §1 + 원문 포인터로 좁히고, 그 외는 원문 그대로.
|
|
442
|
+
|
|
443
|
+
§1 이 있으면 사이드카 답변을 그 표의 `User input` 열에 병합해, 답이 표 안에
|
|
444
|
+
자리하도록 한다(파일 헤더가 선언하는 "답은 User input 열에" 계약을 실제로
|
|
445
|
+
참으로 만든다)."""
|
|
421
446
|
slice_ = _section_1_slice(text)
|
|
422
447
|
if slice_ is None:
|
|
423
448
|
return text
|
|
424
449
|
heading = SECTION_HEADING_PATTERN.search(text)
|
|
425
450
|
assert heading is not None # _section_1_slice returned a slice
|
|
451
|
+
section_body = slice_.rstrip()
|
|
452
|
+
if answers:
|
|
453
|
+
section_body = _reconcile_user_input(section_body, answers)
|
|
426
454
|
return (
|
|
427
455
|
"# Clarification Response (carry-in)\n\n"
|
|
428
456
|
f"- Source report: `{source}`\n"
|
|
@@ -430,5 +458,71 @@ def _clarification_carry_body(source: Path, text: str) -> str:
|
|
|
430
458
|
"section; the report itself is read from the path above when a phase "
|
|
431
459
|
"needs it. Do not re-read the source report to find the answers — they "
|
|
432
460
|
"are in the `User input` column below.\n\n"
|
|
433
|
-
f"{heading.group(0)}\n{
|
|
461
|
+
f"{heading.group(0)}\n{section_body}\n"
|
|
434
462
|
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# The final-report renderer writes `Status: open` / `Status: answered` unquoted
|
|
466
|
+
# in the stacked meta cell; only those two are unresolved. Resolve in place so
|
|
467
|
+
# the meta cell's other fields (ID, Ticket, Kind, Blocks) are left untouched.
|
|
468
|
+
_STATUS_RESOLVE_RE = re.compile(r"(Status:\s*)(?:open|answered)\b", re.IGNORECASE)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
|
|
472
|
+
"""§1 데이터 표의 헤더 줄 인덱스와 `User input` 열 인덱스. 표가 없으면 (-1, -1)."""
|
|
473
|
+
for idx, line in enumerate(lines):
|
|
474
|
+
if not line.lstrip().startswith("|"):
|
|
475
|
+
continue
|
|
476
|
+
cells = [c.lower() for c in _split_pipe_row(line)]
|
|
477
|
+
if "user input" in cells and any(c.startswith("statement") for c in cells):
|
|
478
|
+
return idx, cells.index("user input")
|
|
479
|
+
return -1, -1
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
|
|
483
|
+
"""답이 있고 open/answered 이며 `User input` 칸이 빈 행이면 답을 채우고 Status 를
|
|
484
|
+
resolved 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다. 이미 채워진 칸은
|
|
485
|
+
표에 든 값이 정본이므로 덮어쓰지 않는다.
|
|
486
|
+
|
|
487
|
+
판정은 앵커/백틱을 벗긴 셀(`_split_pipe_row`)로 — 그래야 `_meta_id` 가 스크롤
|
|
488
|
+
앵커의 소문자 slug 대신 진짜 대문자 ID 를 읽는다. 재조립은 원본 셀
|
|
489
|
+
(`split_pipe_row`)로 해서 앵커를 보존한다."""
|
|
490
|
+
norm = _split_pipe_row(line)
|
|
491
|
+
item = parse_meta_cell(norm[0]) if norm else None
|
|
492
|
+
if item is None or item.row_id not in answers:
|
|
493
|
+
return line
|
|
494
|
+
if item.status not in UNRESOLVED_STATUSES:
|
|
495
|
+
return line
|
|
496
|
+
raw = split_pipe_row(line)
|
|
497
|
+
if not (0 <= ui_col < len(raw) and raw[ui_col] == ""):
|
|
498
|
+
return line
|
|
499
|
+
raw[ui_col] = answers[item.row_id]
|
|
500
|
+
raw[0] = _STATUS_RESOLVE_RE.sub(r"\1resolved", raw[0])
|
|
501
|
+
return "| " + " | ".join(to_cell_text(c) for c in raw) + " |"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _reconcile_user_input(section: str, answers: dict[str, str]) -> str:
|
|
505
|
+
"""§1 표에서 사이드카 답이 있는 미해결 행의 빈 `User input` 칸을 답으로 채우고
|
|
506
|
+
Status 를 resolved 로 바꾼 §1 본문을 돌려준다.
|
|
507
|
+
|
|
508
|
+
답의 정본 위치를 §1 표 안으로 옮긴다 — 표만 읽는 승인 게이트·프롬프트
|
|
509
|
+
빌더·검증 워커가 모두 답을 보게 하려는 것. 사이드카는 §1 표 밖 별도 섹션에만
|
|
510
|
+
있어서 표만 신뢰하는 소비자는 그 답을 놓쳤다."""
|
|
511
|
+
lines = section.splitlines()
|
|
512
|
+
header_idx, ui_col = _locate_user_input_column(lines)
|
|
513
|
+
if header_idx < 0:
|
|
514
|
+
return section
|
|
515
|
+
out = list(lines)
|
|
516
|
+
body = False
|
|
517
|
+
for i in range(header_idx + 1, len(lines)):
|
|
518
|
+
line = lines[i]
|
|
519
|
+
if not line.lstrip().startswith("|"):
|
|
520
|
+
if body:
|
|
521
|
+
break
|
|
522
|
+
continue
|
|
523
|
+
if is_separator_row(line):
|
|
524
|
+
body = True
|
|
525
|
+
continue
|
|
526
|
+
if body:
|
|
527
|
+
out[i] = _reconcile_row(line, ui_col, answers)
|
|
528
|
+
return "\n".join(out)
|
|
@@ -78,7 +78,9 @@ def seed_working_state(grouped_input: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
78
78
|
state["stopReason"] = "auto-disabled"
|
|
79
79
|
return state
|
|
80
80
|
|
|
81
|
-
findings, queue = _parse_groups(
|
|
81
|
+
findings, queue = _parse_groups(
|
|
82
|
+
source.get("groups"), analysis_workers, adversarial=config["adversarial"]
|
|
83
|
+
)
|
|
82
84
|
state["findings"] = findings
|
|
83
85
|
state["queueFindingIds"] = queue
|
|
84
86
|
return state
|
|
@@ -206,6 +208,37 @@ def classify_adversarial_round(
|
|
|
206
208
|
return "partial-consensus"
|
|
207
209
|
|
|
208
210
|
|
|
211
|
+
def _round_has_counter_evidence(
|
|
212
|
+
votes: Mapping[str, Mapping[str, Any]],
|
|
213
|
+
) -> bool:
|
|
214
|
+
return any(
|
|
215
|
+
vote.get("verdict") == "disagree"
|
|
216
|
+
and vote.get("disagreeBasis") == "counter-evidence"
|
|
217
|
+
for vote in votes.values()
|
|
218
|
+
if isinstance(vote, Mapping)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _classify_adversarial_history(
|
|
223
|
+
rounds: list[Mapping[str, Any]],
|
|
224
|
+
) -> str | None:
|
|
225
|
+
"""Classify adversarial rounds without erasing earlier counter-evidence."""
|
|
226
|
+
counter_evidence_seen = False
|
|
227
|
+
for row in rounds:
|
|
228
|
+
votes = row.get("votes") if isinstance(row, Mapping) else None
|
|
229
|
+
if not isinstance(votes, Mapping):
|
|
230
|
+
continue
|
|
231
|
+
counter_evidence_seen = (
|
|
232
|
+
counter_evidence_seen or _round_has_counter_evidence(votes)
|
|
233
|
+
)
|
|
234
|
+
classification = classify_adversarial_round(votes)
|
|
235
|
+
if classification == "worker-unique":
|
|
236
|
+
return classification
|
|
237
|
+
if classification is not None and not counter_evidence_seen:
|
|
238
|
+
return classification
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
209
242
|
def apply_round_results(
|
|
210
243
|
state: Mapping[str, Any],
|
|
211
244
|
plan: Mapping[str, Any],
|
|
@@ -251,7 +284,7 @@ def apply_round_results(
|
|
|
251
284
|
{"round": expected_plan["round"], "votes": round_votes}
|
|
252
285
|
)
|
|
253
286
|
classification = (
|
|
254
|
-
|
|
287
|
+
_classify_adversarial_history(finding["rounds"])
|
|
255
288
|
if adversarial
|
|
256
289
|
else classify_collaborative_round(round_votes)
|
|
257
290
|
)
|
|
@@ -1321,20 +1354,21 @@ def _expected_final_classification(
|
|
|
1321
1354
|
rounds = finding.get("rounds")
|
|
1322
1355
|
if not isinstance(rounds, list) or not rounds:
|
|
1323
1356
|
return None
|
|
1357
|
+
if adversarial:
|
|
1358
|
+
try:
|
|
1359
|
+
return _classify_adversarial_history(rounds) or "contested"
|
|
1360
|
+
except ConvergenceContractError:
|
|
1361
|
+
return "contested"
|
|
1324
1362
|
for row in rounds:
|
|
1325
1363
|
if not isinstance(row, Mapping) or not isinstance(row.get("votes"), Mapping):
|
|
1326
1364
|
continue
|
|
1327
1365
|
try:
|
|
1328
|
-
resolved = (
|
|
1329
|
-
classify_adversarial_round(row["votes"])
|
|
1330
|
-
if adversarial
|
|
1331
|
-
else classify_collaborative_round(row["votes"])
|
|
1332
|
-
)
|
|
1366
|
+
resolved = classify_collaborative_round(row["votes"])
|
|
1333
1367
|
except ConvergenceContractError:
|
|
1334
1368
|
continue
|
|
1335
1369
|
if resolved is not None:
|
|
1336
1370
|
return resolved
|
|
1337
|
-
return
|
|
1371
|
+
return _final_collaborative_classification(finding)
|
|
1338
1372
|
|
|
1339
1373
|
|
|
1340
1374
|
def _validate_round_ledger_counts(
|
|
@@ -1347,7 +1381,7 @@ def _validate_round_ledger_counts(
|
|
|
1347
1381
|
if not isinstance(history_row, Mapping):
|
|
1348
1382
|
continue
|
|
1349
1383
|
ledgers = _round_ledgers(findings, round_number)
|
|
1350
|
-
resolved = _resolved_ledger_count(
|
|
1384
|
+
resolved = _resolved_ledger_count(findings, round_number, adversarial)
|
|
1351
1385
|
expected = (len(ledgers), resolved, len(ledgers) - resolved)
|
|
1352
1386
|
actual = (
|
|
1353
1387
|
history_row.get("inputQueueSize"),
|
|
@@ -1381,17 +1415,32 @@ def _round_ledgers(
|
|
|
1381
1415
|
|
|
1382
1416
|
|
|
1383
1417
|
def _resolved_ledger_count(
|
|
1384
|
-
|
|
1418
|
+
findings: list[Any],
|
|
1419
|
+
round_number: int,
|
|
1385
1420
|
adversarial: bool,
|
|
1386
1421
|
) -> int:
|
|
1387
1422
|
resolved = 0
|
|
1388
|
-
for
|
|
1389
|
-
|
|
1423
|
+
for finding in findings:
|
|
1424
|
+
rounds = finding.get("rounds") if isinstance(finding, Mapping) else None
|
|
1425
|
+
if not isinstance(rounds, list):
|
|
1426
|
+
continue
|
|
1427
|
+
current_rounds = [
|
|
1428
|
+
row
|
|
1429
|
+
for row in rounds
|
|
1430
|
+
if isinstance(row, Mapping)
|
|
1431
|
+
and isinstance(row.get("round"), int)
|
|
1432
|
+
and row["round"] <= round_number
|
|
1433
|
+
]
|
|
1434
|
+
current = next(
|
|
1435
|
+
(row for row in current_rounds if row.get("round") == round_number),
|
|
1436
|
+
None,
|
|
1437
|
+
)
|
|
1438
|
+
votes = current.get("votes") if isinstance(current, Mapping) else None
|
|
1390
1439
|
if not isinstance(votes, Mapping):
|
|
1391
1440
|
continue
|
|
1392
1441
|
try:
|
|
1393
1442
|
classification = (
|
|
1394
|
-
|
|
1443
|
+
_classify_adversarial_history(current_rounds)
|
|
1395
1444
|
if adversarial
|
|
1396
1445
|
else classify_collaborative_round(votes)
|
|
1397
1446
|
)
|
|
@@ -1486,7 +1535,7 @@ def _validate_no_reappearance_after_resolution(
|
|
|
1486
1535
|
continue
|
|
1487
1536
|
try:
|
|
1488
1537
|
classification = (
|
|
1489
|
-
|
|
1538
|
+
_classify_adversarial_history(rounds[: index + 1])
|
|
1490
1539
|
if adversarial
|
|
1491
1540
|
else classify_collaborative_round(votes)
|
|
1492
1541
|
)
|
|
@@ -1842,6 +1891,8 @@ def _parse_workers(value: Any) -> list[dict[str, str]]:
|
|
|
1842
1891
|
def _parse_groups(
|
|
1843
1892
|
value: Any,
|
|
1844
1893
|
analysis_workers: list[str],
|
|
1894
|
+
*,
|
|
1895
|
+
adversarial: bool,
|
|
1845
1896
|
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
1846
1897
|
if not isinstance(value, list):
|
|
1847
1898
|
raise ConvergenceContractError("groups must be an array")
|
|
@@ -1855,7 +1906,7 @@ def _parse_groups(
|
|
|
1855
1906
|
raise ConvergenceContractError(f"duplicate findingId: {finding_id}")
|
|
1856
1907
|
seen_ids.add(finding_id)
|
|
1857
1908
|
finding, source_workers = _parse_group(group, index, analysis_workers)
|
|
1858
|
-
if len(source_workers) >= 2:
|
|
1909
|
+
if len(source_workers) >= 2 and not adversarial:
|
|
1859
1910
|
finding["classification"] = "full-consensus"
|
|
1860
1911
|
else:
|
|
1861
1912
|
queue.append(finding_id)
|