okstra 0.186.0 → 0.186.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +1 -1
- package/docs/for-ai/skills/okstra-run.md +1 -1
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/project-structure-overview.md +2 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +13 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/python/okstra_ctl/design_surfaces.py +103 -18
- package/runtime/python/okstra_ctl/worker_prompt_body.py +4 -15
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -5
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-user-response/SKILL.md +4 -2
- package/runtime/templates/reports/html/assets/base.css +19 -9
- package/runtime/templates/reports/html/assets/base.js +21 -0
- package/runtime/templates/reports/html/base.template.html +5 -2
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/templates/reports/html/macros/forms.html +8 -3
- package/runtime/templates/reports/report.js +33 -0
- package/runtime/validators/validate-run.py +106 -29
- package/runtime/validators/validate_analysis_report.py +7 -6
- package/runtime/validators/validate_session_conformance.py +24 -3
package/docs/architecture.md
CHANGED
|
@@ -887,7 +887,7 @@ Entry points:
|
|
|
887
887
|
- Python routing references: `scripts/okstra_ctl/report_views.py` owns the version split; `scripts/okstra_ctl/report_html/router.py` maps all v2 task types to dedicated view-model builders and templates. The HTML JavaScript response serializer remains byte-identical to Python `serialize_user_response`.
|
|
888
888
|
- CLI: `scripts/okstra-render-report-views.py <final-report.data.json|final-report.md>` or delegated Node wrapper `bin/okstra render-views <path>`.
|
|
889
889
|
- Validation: `validators/validate-report-views.py` checks schema/source/template digests, required human fields, form controls, external assets, SVG/table ID parity, and Response ID parity. The v2 renderer rejects an unknown task type instead of falling back to a generic page.
|
|
890
|
-
- User-response sidecar schema source of truth: `templates/reports/user-response.template.md`. The in-session skill reads `okstra user-response show-view`, then the cited `path:line` artifacts and linked plan items that view lists, before asking. The recorded answer still comes from `options[]` via `--option-number`. Task pick, each clarification, and the record confirmation use the current host's native question tool from the relay `native-single` mapping (`AskUserQuestion`, `ask_user_question`, or `request_user_input`). A numbered list is only the fallback when that tool is missing or the option count misses `nativeLimits`.
|
|
890
|
+
- User-response sidecar schema source of truth: `templates/reports/user-response.template.md`. The in-session skill reads `okstra user-response show-view`, then the cited `path:line` artifacts and linked plan items that view lists, before asking. The recorded answer still comes from `options[]` via `--option-number`. Task pick, each clarification, and the record confirmation use the current host's native question tool from the relay `native-single` mapping (`AskUserQuestion`, `ask_user_question`, or `request_user_input`). The picker shows those choices only — it does not append `Enter directly`; custom text uses the host free-text row. A numbered list is only the fallback when that tool is missing or the option count misses `nativeLimits`.
|
|
891
891
|
- In-run user confirmation before an approval blocker follows the same briefing order and the same host picker: the lead investigates cited plan items, worker findings, and files, then asks through the adapter `prompt_user` mapping (`prompts/lead/okstra-lead-contract.md`).
|
|
892
892
|
|
|
893
893
|
Generating a view never modifies the original final-report MD.
|
|
@@ -221,7 +221,7 @@ Inform the user on one line.
|
|
|
221
221
|
Took over as Okstra lead (`<host-runtime>`) for `<taskKey>` (`<task-type>`). Run dir: `<RUN_DIR_RELATIVE_PATH>`. Beginning Phase 1 (context loading).
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
-
For a single-element chain, the end of Step 6 is the end of the run. Step 7 below applies only when the `chain-stages` CSV has 2 or more elements. After
|
|
224
|
+
For a single-element chain, the end of Step 6 is the end of the run. Step 7 below applies only when the `chain-stages` CSV has 2 or more elements. When the run is over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`: open or answered approval blockers → `/okstra-user-response`; awaiting approval → `/okstra-run` → `implementation` or `--approve` (do not start another planning run); `validate-run` failed → one-line cause then `/okstra-run` or `/okstra-inspect recap`; pointer `ready` → `/okstra-run` for that phase.
|
|
225
225
|
|
|
226
226
|
## implementation unattended chaining (chain-stages)
|
|
227
227
|
|
|
@@ -27,8 +27,8 @@ The legacy `list` and `show` JSON commands remain for automation compatibility.
|
|
|
27
27
|
## Flow
|
|
28
28
|
|
|
29
29
|
1. Run `okstra preflight --runtime <host-runtime>` for the current harness. On `Okstra preflight: failed`, show `Reason` and `Recovery`, then stop. On `Okstra preflight: ready`, carry `Project root`, `Project ID`, `Runtime`, and `Relay contract`, then run `okstra paths --field home`.
|
|
30
|
-
2. Read the relay `Wizard interaction relay` JSON. When `native-single` is available and the option count fits `nativeLimits`, call `interactions.native-single.function` (`AskUserQuestion` / `ask_user_question` / `request_user_input` from that field). Do not print a numbered list in chat while the native tool is available. Otherwise render a numbered Markdown list. Do not substitute one host function name for another.
|
|
31
|
-
3. Select a task from `list-view` through that host picker.
|
|
30
|
+
2. Read the relay `Wizard interaction relay` JSON. When `native-single` is available and the option count fits `nativeLimits`, call `interactions.native-single.function` (`AskUserQuestion` / `ask_user_question` / `request_user_input` from that field). Do not print a numbered list in chat while the native tool is available. Otherwise render a numbered Markdown list. Do not substitute one host function name for another. Pass only the choices this step already owns. Do not append `Enter directly`. Claude Other, Grok `z`, and Codex's free-form row are `Enters an answer`; on a numbered list, so is a next message that is not a listed label or its 1-based number. Do not ask a second question for the custom value.
|
|
31
|
+
3. Select a task from `list-view` through that host picker. A host free-text row or unmatched next message is the report path or task key.
|
|
32
32
|
4. Read only `show-view --report <reportPath> --project-root <projectRoot>` for report facts. The view also prints `Why asked`, `Linked plan items`, and `Cited artifacts`.
|
|
33
33
|
5. Read every cited `path:line` under the project root and every linked plan-item definition before asking. Do not search beyond that list. Investigation explains; it never changes `options[]`.
|
|
34
34
|
6. Ask one open clarification at a time in the user's language through the host picker. The question body is why, what is already decided, the fork, and what stays blocked. Do not lead with `Kind`, `Blocks`, `Expected form`, or `C-NNN`. Keep the row id in parentheses at the end.
|
|
@@ -277,6 +277,7 @@ Important modules:
|
|
|
277
277
|
| `report_assembly.py` | Validates the role-owned report inputs and publishes the contract 3.0 record once (assembled in a temp file, then atomically promoted); a bad input aborts with owner / artifact path / field path / reason and preserves the existing `data.json` |
|
|
278
278
|
| `report_projections.py` | Pure projections turning role-owned execution inputs (agent activity, execution status, convergence, design, token usage) into canonical final-report fragments |
|
|
279
279
|
| `report_narrative.py` | Lossless read/write contract for the report-writer-owned narrative Markdown (`report-narrative-<task-type>-<seq>.md`) — the only artifact the report-writer authors under contract 3.0 |
|
|
280
|
+
| `report_synthesis_packet.py` | Builds the frozen, read-only synthesis input packet handed to the report-writer — collects each source's label / owner / path / content digest into `report-writer-synthesis-packet-<task-type>-<seq>.{json,md}`, materialized at dispatch by `initial_prompt_materialization.py` so the writer synthesizes from a byte-stable snapshot. `report_assembly.py` calls `verify_report_synthesis_packet_sources` at publish time so a drifted source aborts the record; schema is `schemas/report-synthesis-packet-v1.0.schema.json` |
|
|
280
281
|
| `approval_decisions.py` | Lead-owned approval-decision input ledger — `disposition` (`select`/`accept-risk`/`request-revision`/`reject`), reach, scope effect, and classification invariants (`correctness-critical`/`noncritical-dissent`/`user-decision`) |
|
|
281
282
|
| `design_snapshot.py` | Builds the design-surface-detector-owned snapshot from the report narrative — reproducible design surfaces plus conservative `PREP-NNN` preparation items (delegates surface detection to `design_surfaces.py`) |
|
|
282
283
|
| `report_markdown.py` | Schema-ordered Markdown serialisation of a data.json subtree for the full reading copy — headings, tables for uniform row sets, prose for narrative fields; field order read from the schema, not from the mapping |
|
|
@@ -696,4 +697,4 @@ Clarifications now live in the unified `## 1. Clarification Items` table. Deprec
|
|
|
696
697
|
|
|
697
698
|
---
|
|
698
699
|
|
|
699
|
-
*Updated: 2026-08-
|
|
700
|
+
*Updated: 2026-08-24 · Source of truth checked against `package.json`, `bin/okstra`, `src/cli-registry.mts`, `src/lib/skill-catalog.mts`, `tools/build.mjs`, `scripts/`, `skills/`, `agents/`, `templates/`, `schemas/`, `validators/`, and tests.*
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -425,7 +425,19 @@ jq -s 'group_by(.errorType) | map({type: .[0].errorType, count: length})' <runDi
|
|
|
425
425
|
|
|
426
426
|
The errors log is informational. Its presence/absence does not affect the final verdict. Do not block report writing on it.
|
|
427
427
|
|
|
428
|
-
After persistence, reply briefly in the resolved Report Language
|
|
428
|
+
After persistence, reply briefly in the resolved Report Language. **Lead this reply with the run's task identity** — state `<task-group>/<task-id>` (or the full `taskKey`) first. Then: completion status, the task-qualified human report path, the report record path, validator result, any remaining blocker. **Close with the user's next action** — one command they can run now. A prohibition (`do not start implementation`) is not a next action. A status dump is not a close.
|
|
429
|
+
|
|
430
|
+
Pick the next action from this table; the first matching row wins:
|
|
431
|
+
|
|
432
|
+
- Open or answered `blocks: approval` rows → `/okstra-user-response` (name the `C-NNN` ids). Do not start implementation and do not re-run planning until those answers exist.
|
|
433
|
+
- `workflow.awaitingApproval` is true → `/okstra-run` → `implementation` (asks `approve_plan_confirm`) or `--approve`. Do not propose another planning run.
|
|
434
|
+
- Phase 7 `validate-run` failed → one line naming the blocking cause, then `/okstra-run` to re-run this phase with the recorded sidecar, or `/okstra-inspect recap` if the resume flags are unknown.
|
|
435
|
+
- Pointer `status: ready` → `/okstra-run` for that `phase`.
|
|
436
|
+
- Otherwise → `/okstra-inspect status` for this task.
|
|
437
|
+
|
|
438
|
+
When the host native picker is available and two of those rows could apply, ask with that picker (recommended first). Do not end the turn after the status dump.
|
|
439
|
+
|
|
440
|
+
**Every run-artifact path in this reply MUST be task-qualified** — report the human report as `.okstra/tasks/<task-group>/<task-id>/runs/<task-type>/reports/final-report-<task-type>-<seq>.html` rooted at the task bundle, NOT the bare `runs/<task-type>/reports/...` form (byte-for-byte identical across every task of the same task-type, so it cannot identify the task). Under that, cite the report record (`.data.json`) and one line to render the full reading copy: `okstra render-final-report <task-qualified data.json>`. The same task-qualified rule applies to the team-state path, resume command path, and any other run-artifact path this reply cites.
|
|
429
441
|
|
|
430
442
|
## Run-scoped worker-resource lifecycle
|
|
431
443
|
|
|
@@ -195,7 +195,7 @@ roles:
|
|
|
195
195
|
- dependency / migration risk assessment (ordering constraints, data backfills, feature-flag prerequisites, repo-internal sequencing)
|
|
196
196
|
- **Cross-Project Dependencies (conditionally required):** when the plan depends on work in another project / repo / published package, add (a) a `kind: cross-project` DM row to `dependencyMigrationRisk`, and (b) a matching `XP-NNN` row to `crossProjectDependencies`. An upstream-precondition row must have concrete `requiredWork` / `verificationSignal` / `howToStart` — `validators/validate-run.py` enforces that a DM `cross-project` ⇒ at least one `direction: upstream-precondition` XP row, and the schema enforces non-empty row fields. A cross-project dependency is recorded as this structured precondition, not as a soft Recommended Next Step. A single-project plan uses an empty array.
|
|
197
197
|
- **recommendedNextSteps policy:** keep the substance of cross-project preconditions/carries in `crossProjectDependencies`, and put in `§3 Recommended Next Steps` only a pointer to that section (`§5.4 Cross-Project Dependencies`) — no double recording.
|
|
198
|
-
- **Resuming from an approval blocker (BLOCKING).** When this report carries
|
|
198
|
+
- **Resuming from an approval blocker (BLOCKING).** When this report carries an unresolved (`open` or `answered`) `blocks: approval` clarification, `recommendedNextSteps[0]` MUST be a command the reader can run now: `/okstra-user-response`, and the `--answered-clarifications` re-run in that step's `text` or `commands`. Do not write "The Okstra lead will …" as the first step. Point them at `okstra recap assemble`, which prints the answered ids, the exact flag value, the sidecar paths, and whether the re-verification would fall back to full. Do not restate those values here: they are unknown while you write, because the user has not answered yet. Do not tell the reader to start another planning run before the answers exist. **Enforced:** `validators/validate-run.py` `_validate_rerun_guidance`.
|
|
199
199
|
- **Asking for approval (BLOCKING).** When `outcome` is `plan-ready`, the gate is `passed` or `passed-with-dissent`, and no `blocks: approval` row is open, one `recommendedNextSteps` entry MUST tell the reader to approve (`--approve` or the in-session wizard). Do not recommend another `implementation-planning` run. **Enforced:** `validators/validate-run.py` `_validate_approval_guidance`.
|
|
200
200
|
- validation checklist (pre / mid / post) — each item is an exact command or observable outcome
|
|
201
201
|
- rollback strategy — exact revert path (commits, flags, migrations) and the signal that triggers rollback
|
|
@@ -181,6 +181,84 @@ def _stage_rows(planning: Mapping[str, Any]) -> dict[int, Mapping[str, Any]]:
|
|
|
181
181
|
|
|
182
182
|
|
|
183
183
|
_LINE_RANGE_SUFFIX = re.compile(r":\d+(?:-\d+)?$")
|
|
184
|
+
_PAREN_SUFFIX = re.compile(r"\s*\([^)]*\)\s*$")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _split_declared_paths(raw: str) -> list[str]:
|
|
188
|
+
"""쉼표·줄바꿈으로 이어진 경로를 파일 단위로 나눈다. `{a,b}` 안 쉼표는 유지.
|
|
189
|
+
|
|
190
|
+
`fileStructure.path` 는 원래 열 수 있는 경로 하나인데, 계획이 여러 파일을
|
|
191
|
+
한 칸에 이어 쓰면 추출기가 그 문자열 전체를 한 경로로 보고 스테이지 매핑에
|
|
192
|
+
실패한다.
|
|
193
|
+
"""
|
|
194
|
+
parts: list[str] = []
|
|
195
|
+
buf: list[str] = []
|
|
196
|
+
depth = 0
|
|
197
|
+
for char in raw:
|
|
198
|
+
if char == "{":
|
|
199
|
+
depth += 1
|
|
200
|
+
buf.append(char)
|
|
201
|
+
elif char == "}":
|
|
202
|
+
depth = max(0, depth - 1)
|
|
203
|
+
buf.append(char)
|
|
204
|
+
elif char in ",\n" and depth == 0:
|
|
205
|
+
token = "".join(buf).strip()
|
|
206
|
+
if token:
|
|
207
|
+
parts.append(token)
|
|
208
|
+
buf = []
|
|
209
|
+
else:
|
|
210
|
+
buf.append(char)
|
|
211
|
+
token = "".join(buf).strip()
|
|
212
|
+
if token:
|
|
213
|
+
parts.append(token)
|
|
214
|
+
return parts
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _step_files_cells(step: Mapping[str, Any]) -> list[str]:
|
|
218
|
+
raw = step.get("files")
|
|
219
|
+
if isinstance(raw, list):
|
|
220
|
+
return [_normalise(item) for item in raw if item]
|
|
221
|
+
text = _normalise(raw)
|
|
222
|
+
return [text] if text else []
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _files_cell_tokens(cell: str) -> list[str]:
|
|
226
|
+
tokens: list[str] = []
|
|
227
|
+
for part in _split_declared_paths(cell) or [cell]:
|
|
228
|
+
token = _LINE_RANGE_SUFFIX.sub("", part)
|
|
229
|
+
token = _PAREN_SUFFIX.sub("", token).strip()
|
|
230
|
+
if token:
|
|
231
|
+
tokens.append(token)
|
|
232
|
+
return tokens
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _cell_covers_path(cell: str, path: str) -> bool:
|
|
236
|
+
path_pattern = rf"(?<![\w./@-]){re.escape(path)}(?![\w./@-])"
|
|
237
|
+
if re.search(path_pattern, cell) is not None:
|
|
238
|
+
return True
|
|
239
|
+
for token in _files_cell_tokens(cell):
|
|
240
|
+
if token == path:
|
|
241
|
+
return True
|
|
242
|
+
if "*" in token and fnmatch.fnmatch(path, token):
|
|
243
|
+
return True
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _stages_touching_path(
|
|
248
|
+
path: str,
|
|
249
|
+
stages: Mapping[int, Mapping[str, Any]],
|
|
250
|
+
) -> list[int]:
|
|
251
|
+
hits: list[int] = []
|
|
252
|
+
for stage_number, stage in stages.items():
|
|
253
|
+
cells = [
|
|
254
|
+
cell
|
|
255
|
+
for step in stage.get("stepwiseExecution") or []
|
|
256
|
+
if isinstance(step, Mapping)
|
|
257
|
+
for cell in _step_files_cells(step)
|
|
258
|
+
]
|
|
259
|
+
if any(_cell_covers_path(cell, path) for cell in cells):
|
|
260
|
+
hits.append(stage_number)
|
|
261
|
+
return hits
|
|
184
262
|
|
|
185
263
|
|
|
186
264
|
def _stages_for_selected_path(
|
|
@@ -200,21 +278,27 @@ def _stages_for_selected_path(
|
|
|
200
278
|
Zero stages is still an error: the recommended option declares a file that
|
|
201
279
|
no step creates or edits.
|
|
202
280
|
"""
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
for step in stage.get("stepwiseExecution") or []
|
|
210
|
-
if isinstance(step, Mapping)
|
|
211
|
-
]
|
|
212
|
-
if any(re.search(path_pattern, cell) is not None for cell in files):
|
|
213
|
-
matched.append(stage_number)
|
|
214
|
-
if not matched:
|
|
281
|
+
declared = [
|
|
282
|
+
_LINE_RANGE_SUFFIX.sub("", _normalise(part))
|
|
283
|
+
for part in (_split_declared_paths(path) or [path])
|
|
284
|
+
]
|
|
285
|
+
declared = [part for part in declared if part]
|
|
286
|
+
if not declared:
|
|
215
287
|
raise DesignSurfaceError(
|
|
216
288
|
f"selected option path {path!r} is not mapped to a stage"
|
|
217
289
|
)
|
|
290
|
+
matched: list[int] = []
|
|
291
|
+
seen: set[int] = set()
|
|
292
|
+
for part in declared:
|
|
293
|
+
hits = _stages_touching_path(part, stages)
|
|
294
|
+
if not hits:
|
|
295
|
+
raise DesignSurfaceError(
|
|
296
|
+
f"selected option path {part!r} is not mapped to a stage"
|
|
297
|
+
)
|
|
298
|
+
for stage_number in hits:
|
|
299
|
+
if stage_number not in seen:
|
|
300
|
+
seen.add(stage_number)
|
|
301
|
+
matched.append(stage_number)
|
|
218
302
|
return matched
|
|
219
303
|
|
|
220
304
|
|
|
@@ -243,12 +327,13 @@ def detect_design_surfaces(
|
|
|
243
327
|
continue
|
|
244
328
|
path = str(row.get("path") or "")
|
|
245
329
|
for stage_number in _stages_for_selected_path(path, stages):
|
|
246
|
-
for
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
330
|
+
for declared in _split_declared_paths(path) or [path]:
|
|
331
|
+
for kind, evidence in _matching_rule_evidence(
|
|
332
|
+
step=None,
|
|
333
|
+
field="files",
|
|
334
|
+
value=declared,
|
|
335
|
+
):
|
|
336
|
+
grouped.setdefault((stage_number, kind), []).append(evidence)
|
|
252
337
|
rule_order = {rule.kind: index for index, rule in enumerate(RULES)}
|
|
253
338
|
return [
|
|
254
339
|
DesignSurfaceTrigger(stage, kind, tuple(grouped[(stage, kind)]))
|
|
@@ -6,25 +6,14 @@ from typing import Any, Mapping, Sequence
|
|
|
6
6
|
from .worker_prompt_policy import PromptPlan
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
_ANALYSIS_WORKER_LABELS = {
|
|
10
|
-
"claude": "Claude worker",
|
|
11
|
-
"codex": "Codex worker",
|
|
12
|
-
"antigravity": "Antigravity worker",
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
9
|
def analysis_worker_label(worker_id: str) -> str:
|
|
17
10
|
"""The role label this worker's prompt body is titled with.
|
|
18
11
|
|
|
19
|
-
The
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
group must normalize away, so `worker_prompt_contract` erases exactly what
|
|
23
|
-
this function returns rather than restating the map — an enumeration that
|
|
24
|
-
covered only the map's keys let `# grok worker Dispatch` through and failed
|
|
25
|
-
every roster carrying grok or kimi before publication.
|
|
12
|
+
The label is `{worker_id} worker` for every id. A three-provider display
|
|
13
|
+
map made grok/kimi a second branch, and any consumer that restated the map
|
|
14
|
+
treated those ids as unnamed.
|
|
26
15
|
"""
|
|
27
|
-
return
|
|
16
|
+
return f"{worker_id} worker"
|
|
28
17
|
|
|
29
18
|
|
|
30
19
|
def analysis_prompt_body(
|
|
@@ -159,11 +159,8 @@ def _worker_label_pattern(worker_ids: Iterable[str]) -> re.Pattern[str] | None:
|
|
|
159
159
|
"""Match the role label the body renderer titled each compared worker with.
|
|
160
160
|
|
|
161
161
|
Built from `analysis_worker_label`, the same function that writes the label,
|
|
162
|
-
so
|
|
163
|
-
|
|
164
|
-
named only Claude / Codex / Antigravity, `# grok worker Dispatch` survived
|
|
165
|
-
normalization, and every run rostering grok or kimi failed the equality group
|
|
166
|
-
before publication with no prompt defect to fix.
|
|
162
|
+
so every worker id in the comparison group is covered. Restating a
|
|
163
|
+
three-provider list here is what forked the roster.
|
|
167
164
|
"""
|
|
168
165
|
labels = sorted(
|
|
169
166
|
{
|
|
@@ -390,4 +390,4 @@ Do not read the wizard state file directly. `okstra wizard outcome` exposes any
|
|
|
390
390
|
|
|
391
391
|
- Echo each captured answer (`result.echo`) on one short line so the user sees what was registered.
|
|
392
392
|
- Never invent identity; if a `text` prompt returns an empty answer where the wizard rejects it, the user must retry.
|
|
393
|
-
- After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. After
|
|
393
|
+
- After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. When the lead (or this skill, after the lead returns) reports the run over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`, read `workflow.awaitingApproval` and the next-phase pointer from the task manifest. Open or answered `blocks: approval` rows → `/okstra-user-response`. Awaiting approval → `/okstra-run` → `implementation` or `--approve` (do not start another planning run). Phase 7 `validate-run` failed → one-line cause, then `/okstra-run` to re-run this phase with the sidecar, or `/okstra-inspect recap`. Pointer `status: ready` → `/okstra-run` for that phase. Otherwise `/okstra-inspect status`.
|
|
@@ -59,6 +59,8 @@ Read the absolute path in the fixed `Relay contract` line. In that file, take th
|
|
|
59
59
|
- When `native-single` is available and the option count fits `nativeLimits` (unique labels, within min/max): call `interactions.native-single.function` once with one question and every option as `{label, description}` in original order. Do not print a numbered list in chat while the native tool is available. Claude Code's function is `AskUserQuestion`, Grok's is `ask_user_question`, Codex's is `request_user_input` — copy the relay field; do not substitute one name for another.
|
|
60
60
|
- Otherwise render a 1-based numbered Markdown list and wait for the next message. Do not drop options to force the native tool.
|
|
61
61
|
|
|
62
|
+
Pass only the choices this step already owns — the `list-view` rows, the report `options[]`, or the two confirmation labels. Do not append `Enter directly`. Claude Other, Grok `z`, and Codex's free-form row already collect a custom answer; that row is `Enters an answer`. When native-single is unavailable, a next message that is not a listed label or its 1-based number is the same `Enters an answer`. Do not ask a second question for the custom value.
|
|
63
|
+
|
|
62
64
|
Never invent a picker function. Never ask the user to type a number when the native tool is available.
|
|
63
65
|
|
|
64
66
|
## Step 1: Select a task from the fixed list view
|
|
@@ -69,7 +71,7 @@ okstra user-response list-view --home <resolved-home> --project <projectId> --li
|
|
|
69
71
|
|
|
70
72
|
The view gives `Task key`, `Task type`, `Report`, open-item counts, and readability status. If the count is zero, answer `No task has open clarification items.` and stop. Do not continue with an unreadable entry.
|
|
71
73
|
|
|
72
|
-
Present up to three task choices through the host picker.
|
|
74
|
+
Present up to three task choices through the host picker. A host free-text row or unmatched next message is the report path or task key.
|
|
73
75
|
|
|
74
76
|
## Step 2: Read the fixed report view
|
|
75
77
|
|
|
@@ -79,7 +81,7 @@ okstra user-response show-view --report <reportPath> --project-root <projectRoot
|
|
|
79
81
|
|
|
80
82
|
The view contains the report identity, contract version, every open clarification question, its expected form, its current response and disposition, its options, approval context, plan option candidates, current plan decision, resolved context, why the row is asked, linked plan items, and cited artifacts. Question text and `options[]` come only from this view. Do not open a report record to select fields.
|
|
81
83
|
|
|
82
|
-
Each entry in `options[]` corresponds to `{role, answer, rationale, scopeImpact, addedWork, directionChange, disposition}`. Put the `recommended` option first and suffix its label with `(Recommended)`. Then put the alternatives in view order
|
|
84
|
+
Each entry in `options[]` corresponds to `{role, answer, rationale, scopeImpact, addedWork, directionChange, disposition}`. Put the `recommended` option first and suffix its label with `(Recommended)`. Then put the alternatives in view order.
|
|
83
85
|
|
|
84
86
|
Contract 3.0 options also expose `reach` and `scopeEffects`. Contract 3.0 approval-blocking rows expose `approvalContext`.
|
|
85
87
|
|
|
@@ -133,27 +133,34 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
|
|
|
133
133
|
display: flex;
|
|
134
134
|
flex-direction: column;
|
|
135
135
|
align-items: flex-end;
|
|
136
|
+
gap: .4rem;
|
|
136
137
|
}
|
|
137
|
-
.back-to-top {
|
|
138
|
+
.back-to-top-actions { display: flex; gap: .4rem; }
|
|
139
|
+
.back-to-top,
|
|
140
|
+
.back-to-top-toggle {
|
|
138
141
|
padding: .55rem .9rem;
|
|
139
142
|
border-radius: 8px;
|
|
140
|
-
border: 1px solid color-mix(in srgb, CanvasText
|
|
141
|
-
background:
|
|
143
|
+
border: 1px solid color-mix(in srgb, CanvasText 40%, transparent);
|
|
144
|
+
background: Canvas;
|
|
142
145
|
color: CanvasText;
|
|
143
146
|
text-decoration: none;
|
|
144
147
|
font: inherit;
|
|
145
148
|
font-size: .9rem;
|
|
146
|
-
|
|
149
|
+
font-weight: 600;
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
box-shadow: 0 2px 10px color-mix(in srgb, CanvasText 28%, transparent);
|
|
147
152
|
}
|
|
148
|
-
.back-to-top:hover
|
|
149
|
-
.back-to-top:
|
|
153
|
+
.back-to-top:hover,
|
|
154
|
+
.back-to-top-toggle:hover { background: color-mix(in srgb, CanvasText 12%, Canvas); }
|
|
155
|
+
.back-to-top:focus-visible,
|
|
156
|
+
.back-to-top-toggle:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
|
|
150
157
|
.back-to-top-index {
|
|
151
158
|
display: none;
|
|
152
159
|
box-sizing: border-box;
|
|
153
160
|
width: min(22rem, calc(100vw - 2.4rem));
|
|
154
161
|
max-height: min(70vh, 32rem);
|
|
155
162
|
overflow: auto;
|
|
156
|
-
margin: 0
|
|
163
|
+
margin: 0;
|
|
157
164
|
padding: .8rem 1rem;
|
|
158
165
|
border-radius: 12px;
|
|
159
166
|
border: 1px solid color-mix(in srgb, CanvasText 14%, transparent);
|
|
@@ -163,10 +170,13 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
|
|
|
163
170
|
.back-to-top-index ol { margin: 0; padding-left: 1.2rem; }
|
|
164
171
|
.back-to-top-index li { margin: .25em 0; }
|
|
165
172
|
.back-to-top-index a { color: inherit; }
|
|
173
|
+
.back-to-top-wrap.is-open .back-to-top-index { display: block; }
|
|
166
174
|
@media (hover: hover) {
|
|
167
|
-
.back-to-top-wrap:hover .back-to-top-index
|
|
168
|
-
.back-to-top-wrap:focus-within .back-to-top-index { display: block; }
|
|
175
|
+
.back-to-top-wrap:hover .back-to-top-index { display: block; }
|
|
169
176
|
}
|
|
177
|
+
.clarification-option { cursor: pointer; border-radius: 10px; padding: .6rem .7rem; }
|
|
178
|
+
.clarification-option.is-selected { background: color-mix(in srgb, Highlight 12%, Canvas); }
|
|
179
|
+
.clarification-item.is-closed .clarification-option { cursor: default; }
|
|
170
180
|
/* A seven-column table needs 42em of floor, more than a phone can give. */
|
|
171
181
|
@media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } nav.report-index ol { columns: 1; } }
|
|
172
182
|
@media print { .skip-link, .back-to-top-wrap, script { display: none !important; } body { color: #000; background: #fff; } section { break-inside: avoid; border-color: #bbb; } .visualization-fallback { display: table; } }
|
|
@@ -2,4 +2,25 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
document.documentElement.classList.add("js-enabled");
|
|
5
|
+
|
|
6
|
+
// 목차는 hover 만으로는 안 열린다. 클릭으로 연다.
|
|
7
|
+
var wrap = document.querySelector(".back-to-top-wrap");
|
|
8
|
+
var toggle = wrap && wrap.querySelector(".back-to-top-toggle");
|
|
9
|
+
if (!wrap || !toggle) return;
|
|
10
|
+
|
|
11
|
+
function setOpen(open) {
|
|
12
|
+
wrap.classList.toggle("is-open", open);
|
|
13
|
+
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
toggle.addEventListener("click", function (event) {
|
|
17
|
+
event.stopPropagation();
|
|
18
|
+
setOpen(!wrap.classList.contains("is-open"));
|
|
19
|
+
});
|
|
20
|
+
document.addEventListener("click", function (event) {
|
|
21
|
+
if (!wrap.contains(event.target)) setOpen(false);
|
|
22
|
+
});
|
|
23
|
+
document.addEventListener("keydown", function (event) {
|
|
24
|
+
if (event.key === "Escape") setOpen(false);
|
|
25
|
+
});
|
|
5
26
|
})();
|
|
@@ -115,8 +115,11 @@
|
|
|
115
115
|
<pre id="user-response-output" aria-live="polite"></pre>
|
|
116
116
|
</footer>{% endif %}
|
|
117
117
|
<div class="back-to-top-wrap">
|
|
118
|
-
<nav class="back-to-top-index" aria-label="{{ t('base.contents') }}"><!--report-index-items--></nav>
|
|
119
|
-
<
|
|
118
|
+
<nav class="back-to-top-index" id="back-to-top-index" aria-label="{{ t('base.contents') }}"><!--report-index-items--></nav>
|
|
119
|
+
<div class="back-to-top-actions">
|
|
120
|
+
<button type="button" class="back-to-top-toggle" aria-expanded="false" aria-controls="back-to-top-index">{{ t('base.contents') }}</button>
|
|
121
|
+
<a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
|
|
122
|
+
</div>
|
|
120
123
|
</div>
|
|
121
124
|
<script id="run-meta" type="application/json">{{ {
|
|
122
125
|
"task-key": runMeta.task_key,
|
|
@@ -159,6 +159,7 @@
|
|
|
159
159
|
"count-answered-questions": "{count} answered questions",
|
|
160
160
|
"your-answer-to-id": "Your answer to {id}",
|
|
161
161
|
"choose-one": "Choose one",
|
|
162
|
+
"other-answer": "Other (type your own)",
|
|
162
163
|
"recommended": "Recommended",
|
|
163
164
|
"scope-impact": "Scope",
|
|
164
165
|
"added-work": "Added work",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
{% if options %}
|
|
79
79
|
<ol class="clarification-options">
|
|
80
80
|
{% for option in options %}
|
|
81
|
-
<li class="clarification-option{% if option.role == 'recommended' %} is-recommended{% endif %}">
|
|
81
|
+
<li class="clarification-option{% if option.role == 'recommended' %} is-recommended{% endif %}" data-option-value="{{ option.answer }}">
|
|
82
82
|
<p class="clarification-option-answer">{{ option.answer }}{% if option.role == 'recommended' %} <span class="badge">{{ t('macros.forms.recommended') }}</span>{% endif %}</p>
|
|
83
83
|
{% if option.disposition | default(None) %}<p class="clarification-option-disposition"><code>{{ option.disposition }}</code></p>{% endif %}
|
|
84
84
|
<p class="clarification-option-rationale">{{ option.rationale }}</p>
|
|
@@ -92,11 +92,16 @@
|
|
|
92
92
|
</ol>
|
|
93
93
|
{% endif %}
|
|
94
94
|
<label for="response-{{ row.id }}">{{ t('macros.forms.your-answer-to-id') | replace('{id}', row.id) }}</label>
|
|
95
|
-
{% if
|
|
95
|
+
{% if options %}
|
|
96
|
+
{% set pick = namespace(has_other=false, current=row.userInput | default('')) %}
|
|
97
|
+
{% for option in options %}{% if option.answer == '__other__' %}{% set pick.has_other = true %}{% endif %}{% endfor %}
|
|
98
|
+
{% set is_custom = pick.current and pick.current not in (options | map(attribute='answer') | list) %}
|
|
96
99
|
<select id="response-{{ row.id }}" data-response-id="{{ row.id }}"{% if is_closed %} disabled{% endif %}>
|
|
97
100
|
<option value="">{{ t('macros.forms.choose-one') }}</option>
|
|
98
|
-
{% for option in options %}<option value="{{ option.answer }}" data-disposition="{{ option.disposition | default('') }}"{% if option.role == 'recommended' %} data-recommended="true"{% endif %}{% if
|
|
101
|
+
{% for option in options %}<option value="{{ option.answer }}" data-disposition="{{ option.disposition | default('') }}"{% if option.role == 'recommended' %} data-recommended="true"{% endif %}{% if pick.current == option.answer %} selected{% endif %}>{{ option.answer }}</option>{% endfor %}
|
|
102
|
+
{% if not pick.has_other %}<option value="__other__"{% if is_custom %} selected{% endif %}>{{ t('macros.forms.other-answer') }}</option>{% endif %}
|
|
99
103
|
</select>
|
|
104
|
+
{% if not pick.has_other %}<textarea data-other-for="{{ row.id }}" rows="2"{% if is_closed %} disabled{% endif %}{% if not is_custom %} hidden{% endif %}>{% if is_custom %}{{ pick.current }}{% endif %}</textarea>{% endif %}
|
|
100
105
|
{% else %}
|
|
101
106
|
<textarea id="response-{{ row.id }}" data-response-id="{{ row.id }}" rows="4"{% if is_closed %} disabled{% endif %}>{{ row.userInput | default('') }}</textarea>
|
|
102
107
|
{% endif %}
|
|
@@ -274,6 +274,38 @@
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// 선택 카드는 보여 주기만 하면 고른 것처럼 보이지만 값이 안 실린다.
|
|
278
|
+
// 카드를 누르면 같은 칸의 select 를 맞춘다.
|
|
279
|
+
function bindOptionCardPicks() {
|
|
280
|
+
var articles = document.querySelectorAll("article[data-response-id]");
|
|
281
|
+
for (var i = 0; i < articles.length; i++) {
|
|
282
|
+
(function (article) {
|
|
283
|
+
var sel = article.querySelector("select[data-response-id]");
|
|
284
|
+
if (!sel) return;
|
|
285
|
+
var cards = article.querySelectorAll(".clarification-option[data-option-value]");
|
|
286
|
+
var sync = function () {
|
|
287
|
+
for (var c = 0; c < cards.length; c++) {
|
|
288
|
+
cards[c].classList.toggle(
|
|
289
|
+
"is-selected",
|
|
290
|
+
cards[c].getAttribute("data-option-value") === sel.value
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
for (var c = 0; c < cards.length; c++) {
|
|
295
|
+
cards[c].addEventListener("click", function (event) {
|
|
296
|
+
if (sel.disabled) return;
|
|
297
|
+
if (event.target.closest && event.target.closest("a")) return;
|
|
298
|
+
sel.value = event.currentTarget.getAttribute("data-option-value") || "";
|
|
299
|
+
sel.dispatchEvent(new Event("change"));
|
|
300
|
+
sync();
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
sel.addEventListener("change", sync);
|
|
304
|
+
sync();
|
|
305
|
+
})(articles[i]);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
277
309
|
function quotedReviewField(label, value) {
|
|
278
310
|
var cleaned = trimMultiline(value);
|
|
279
311
|
if (!cleaned) return "- " + label + ":\n";
|
|
@@ -547,6 +579,7 @@
|
|
|
547
579
|
setReaderMode("action");
|
|
548
580
|
}
|
|
549
581
|
bindOtherInputToggle();
|
|
582
|
+
bindOptionCardPicks();
|
|
550
583
|
}
|
|
551
584
|
|
|
552
585
|
if (typeof window !== "undefined") {
|
|
@@ -4117,6 +4117,77 @@ def _state_classification(item: dict, gate_class: str) -> str:
|
|
|
4117
4117
|
return "dissent-isolated" if dissenting == 1 else "partial-consensus"
|
|
4118
4118
|
|
|
4119
4119
|
|
|
4120
|
+
def _clarification_resolution(row: dict, context: dict) -> dict | None:
|
|
4121
|
+
"""계약 3.0 은 행의 `resolution`, 2.0 은 `approvalContext.resolution`."""
|
|
4122
|
+
for candidate in (row.get("resolution"), context.get("resolution")):
|
|
4123
|
+
if isinstance(candidate, dict):
|
|
4124
|
+
return candidate
|
|
4125
|
+
return None
|
|
4126
|
+
|
|
4127
|
+
|
|
4128
|
+
def _clarification_ids_on_activity(activity: dict) -> set[str]:
|
|
4129
|
+
refs: set[str] = set()
|
|
4130
|
+
for key in ("clarificationRefs", "evidenceRefs"):
|
|
4131
|
+
for value in activity.get(key) or []:
|
|
4132
|
+
if isinstance(value, str) and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(value):
|
|
4133
|
+
refs.add(value)
|
|
4134
|
+
return refs
|
|
4135
|
+
|
|
4136
|
+
|
|
4137
|
+
def _plan_item_ids_for_clarification(
|
|
4138
|
+
row: dict, context: dict, data: dict,
|
|
4139
|
+
) -> list[str]:
|
|
4140
|
+
"""이 C 행이 가리키는 계획 항목.
|
|
4141
|
+
|
|
4142
|
+
계약 3.0 `approvalContext` 는 `planItemIds` 를 갖지 않는다. 활동
|
|
4143
|
+
`evidenceRefs` / `clarificationRefs` 와 `planItems[].clarificationRefs` 가
|
|
4144
|
+
역추적이다. 이 C 만 인용한 활동을 묶음 활동보다 앞세운다.
|
|
4145
|
+
"""
|
|
4146
|
+
linked = [
|
|
4147
|
+
item_id
|
|
4148
|
+
for item_id in (context.get("planItemIds") or [])
|
|
4149
|
+
if isinstance(item_id, str) and item_id
|
|
4150
|
+
]
|
|
4151
|
+
if linked:
|
|
4152
|
+
return linked
|
|
4153
|
+
row_id = str(row.get("id") or "")
|
|
4154
|
+
if not row_id:
|
|
4155
|
+
return []
|
|
4156
|
+
singleton: list[str] = []
|
|
4157
|
+
bulk: list[str] = []
|
|
4158
|
+
for activity in data.get("agentActivity") or []:
|
|
4159
|
+
if not isinstance(activity, dict):
|
|
4160
|
+
continue
|
|
4161
|
+
refs = _clarification_ids_on_activity(activity)
|
|
4162
|
+
if row_id not in refs:
|
|
4163
|
+
continue
|
|
4164
|
+
ids = [
|
|
4165
|
+
item_id
|
|
4166
|
+
for item_id in (activity.get("planItemIds") or [])
|
|
4167
|
+
if isinstance(item_id, str) and item_id
|
|
4168
|
+
]
|
|
4169
|
+
if refs == {row_id}:
|
|
4170
|
+
singleton.extend(ids)
|
|
4171
|
+
else:
|
|
4172
|
+
bulk.extend(ids)
|
|
4173
|
+
if singleton or bulk:
|
|
4174
|
+
return singleton or bulk
|
|
4175
|
+
items = (
|
|
4176
|
+
((data.get("implementationPlanning") or {}).get("planBodyVerification")
|
|
4177
|
+
or {}).get("planItems") or []
|
|
4178
|
+
)
|
|
4179
|
+
return [
|
|
4180
|
+
str(item.get("id") or "")
|
|
4181
|
+
for item in items
|
|
4182
|
+
if isinstance(item, dict)
|
|
4183
|
+
and row_id in {
|
|
4184
|
+
ref for ref in (item.get("clarificationRefs") or [])
|
|
4185
|
+
if isinstance(ref, str)
|
|
4186
|
+
}
|
|
4187
|
+
and item.get("id")
|
|
4188
|
+
]
|
|
4189
|
+
|
|
4190
|
+
|
|
4120
4191
|
def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
|
|
4121
4192
|
"""Plan items whose remaining dissent the user explicitly accepted."""
|
|
4122
4193
|
accepted: set[str] = set()
|
|
@@ -4126,22 +4197,28 @@ def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
|
|
|
4126
4197
|
context = row.get("approvalContext")
|
|
4127
4198
|
if not isinstance(context, dict):
|
|
4128
4199
|
continue
|
|
4129
|
-
resolution = context
|
|
4200
|
+
resolution = _clarification_resolution(row, context)
|
|
4130
4201
|
if (
|
|
4131
|
-
row.get("status")
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
and _approval_context_activity_refs_exist(
|
|
4137
|
-
data, str(row.get("id") or ""), context, resolution
|
|
4138
|
-
)
|
|
4202
|
+
row.get("status") != "resolved"
|
|
4203
|
+
or context.get("classification") != "noncritical-dissent"
|
|
4204
|
+
or not isinstance(resolution, dict)
|
|
4205
|
+
or resolution.get("disposition") != "accept-risk"
|
|
4206
|
+
or not str(resolution.get("userText") or "").strip()
|
|
4139
4207
|
):
|
|
4208
|
+
continue
|
|
4209
|
+
row_id = str(row.get("id") or "")
|
|
4210
|
+
if context.get("planItemIds") or context.get("activityIds"):
|
|
4211
|
+
if not _approval_context_activity_refs_exist(
|
|
4212
|
+
data, row_id, context, resolution
|
|
4213
|
+
):
|
|
4214
|
+
continue
|
|
4140
4215
|
accepted.update(
|
|
4141
4216
|
item_id
|
|
4142
4217
|
for item_id in context.get("planItemIds") or []
|
|
4143
4218
|
if isinstance(item_id, str)
|
|
4144
4219
|
)
|
|
4220
|
+
continue
|
|
4221
|
+
accepted.update(_plan_item_ids_for_clarification(row, context, data))
|
|
4145
4222
|
return accepted
|
|
4146
4223
|
|
|
4147
4224
|
|
|
@@ -4957,14 +5034,6 @@ def _validate_resolved_approval(
|
|
|
4957
5034
|
f"final-report data.json: resolved approval clarification `{row_id}` "
|
|
4958
5035
|
"requires non-empty resolution.checkRefs."
|
|
4959
5036
|
)
|
|
4960
|
-
if (
|
|
4961
|
-
context.get("classification") == "noncritical-dissent"
|
|
4962
|
-
and resolution.get("disposition") != "accept-risk"
|
|
4963
|
-
):
|
|
4964
|
-
failures.append(
|
|
4965
|
-
f"final-report data.json: resolved noncritical-dissent `{row_id}` "
|
|
4966
|
-
"requires an explicit accept-risk disposition."
|
|
4967
|
-
)
|
|
4968
5037
|
|
|
4969
5038
|
|
|
4970
5039
|
def _has_successful_targeted_reverification(item: dict) -> bool:
|
|
@@ -6173,6 +6242,7 @@ def _validate_approval_clarification_backtrace(
|
|
|
6173
6242
|
|
|
6174
6243
|
|
|
6175
6244
|
_RERUN_FLAG = "--answered-clarifications"
|
|
6245
|
+
_USER_RESPONSE_HINT = re.compile(r"okstra-user-response", re.IGNORECASE)
|
|
6176
6246
|
_APPROVE_HINT = re.compile(r"--approve|\bapprov", re.IGNORECASE)
|
|
6177
6247
|
|
|
6178
6248
|
|
|
@@ -6190,9 +6260,11 @@ def _next_step_texts(steps: object) -> list[str]:
|
|
|
6190
6260
|
return texts
|
|
6191
6261
|
|
|
6192
6262
|
|
|
6193
|
-
def
|
|
6263
|
+
def _has_unresolved_approval_blocker(data: dict) -> bool:
|
|
6194
6264
|
return any(
|
|
6195
|
-
isinstance(row, dict)
|
|
6265
|
+
isinstance(row, dict)
|
|
6266
|
+
and row.get("blocks") == "approval"
|
|
6267
|
+
and str(row.get("status") or "") in {"open", "answered"}
|
|
6196
6268
|
for row in data.get("clarificationItems") or []
|
|
6197
6269
|
)
|
|
6198
6270
|
|
|
@@ -6220,19 +6292,20 @@ def _validate_rerun_guidance(data: dict, failures: list[str]) -> None:
|
|
|
6220
6292
|
"""
|
|
6221
6293
|
if (data.get("header") or {}).get("taskType") != "implementation-planning":
|
|
6222
6294
|
return
|
|
6223
|
-
|
|
6224
|
-
if not has_blocker:
|
|
6295
|
+
if not _has_unresolved_approval_blocker(data):
|
|
6225
6296
|
return
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6297
|
+
texts = _next_step_texts(data.get("recommendedNextSteps"))
|
|
6298
|
+
if any(_RERUN_FLAG in text for text in texts) and any(
|
|
6299
|
+
_USER_RESPONSE_HINT.search(text) for text in texts
|
|
6300
|
+
):
|
|
6229
6301
|
return
|
|
6230
6302
|
failures.append(
|
|
6231
|
-
"final-report data.json: this plan withholds approval on
|
|
6303
|
+
"final-report data.json: this plan withholds approval on an unresolved "
|
|
6232
6304
|
"`blocks: approval` clarification, but no `recommendedNextSteps` entry "
|
|
6233
|
-
|
|
6234
|
-
"step's `text` or one of its
|
|
6235
|
-
"prints the exact ids and flag
|
|
6305
|
+
"tells the reader the command to run now — name `/okstra-user-response` "
|
|
6306
|
+
f"and the `{_RERUN_FLAG}` re-run in a step's `text` or one of its "
|
|
6307
|
+
"`commands`. `okstra recap assemble` prints the exact ids and flag "
|
|
6308
|
+
"value once the answers are recorded."
|
|
6236
6309
|
)
|
|
6237
6310
|
|
|
6238
6311
|
|
|
@@ -6247,7 +6320,7 @@ def _validate_approval_guidance(data: dict, failures: list[str]) -> None:
|
|
|
6247
6320
|
planning = data.get("implementationPlanning")
|
|
6248
6321
|
if not isinstance(planning, dict) or planning.get("outcome") != "plan-ready":
|
|
6249
6322
|
return
|
|
6250
|
-
if
|
|
6323
|
+
if _has_unresolved_approval_blocker(data) or _planning_gate_blocks_approval(data):
|
|
6251
6324
|
return
|
|
6252
6325
|
if _report_already_approved(data):
|
|
6253
6326
|
return
|
|
@@ -7245,6 +7318,7 @@ def _validate_unresolved_tie_was_reverified(
|
|
|
7245
7318
|
for item in pbv.get("planItems") or []
|
|
7246
7319
|
if isinstance(item, dict)
|
|
7247
7320
|
and not item.get("carriedForwardFromSeq")
|
|
7321
|
+
and _stage_scope_bucket(item, pbv) == "in-scope"
|
|
7248
7322
|
and _is_unsettled_tie(item)
|
|
7249
7323
|
})
|
|
7250
7324
|
if not unsettled:
|
|
@@ -7272,11 +7346,14 @@ def _validate_tie_received_extra_vote(
|
|
|
7272
7346
|
pbv = ip.get("planBodyVerification")
|
|
7273
7347
|
if not isinstance(pbv, dict):
|
|
7274
7348
|
return
|
|
7349
|
+
accepted = _resolved_noncritical_dissent_ids(data)
|
|
7275
7350
|
missing = sorted({
|
|
7276
7351
|
str(item.get("id") or "").strip()
|
|
7277
7352
|
for item in pbv.get("planItems") or []
|
|
7278
7353
|
if isinstance(item, dict)
|
|
7279
7354
|
and not item.get("carriedForwardFromSeq")
|
|
7355
|
+
and str(item.get("id") or "").strip() not in accepted
|
|
7356
|
+
and _stage_scope_bucket(item, pbv) == "in-scope"
|
|
7280
7357
|
and _is_even_blocking_split(item)
|
|
7281
7358
|
and _distinct_verdict_workers(item) < 3
|
|
7282
7359
|
})
|
|
@@ -54,11 +54,12 @@ _ANALYSIS_PARENT_KEYS = {
|
|
|
54
54
|
"feature-analysis": "featureAnalysis",
|
|
55
55
|
"change-impact-analysis": "changeImpactAnalysis",
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_report_writer_role(role: str) -> bool:
|
|
60
|
+
return "reportwriter" in re.sub(r"[^a-z0-9]", "", role.lower())
|
|
61
|
+
|
|
62
|
+
|
|
62
63
|
_FINAL_ANALYSIS_REPORT_RE = re.compile(
|
|
63
64
|
r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
|
|
64
65
|
r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
|
|
@@ -467,7 +468,7 @@ def validate_analysis_semantics(
|
|
|
467
468
|
) -> None:
|
|
468
469
|
required_roles = _required_worker_roles(run_manifest, errors)
|
|
469
470
|
analysis_worker_roles = tuple(
|
|
470
|
-
role for role in required_roles if role
|
|
471
|
+
role for role in required_roles if not _is_report_writer_role(role)
|
|
471
472
|
)
|
|
472
473
|
verdict_roles_unambiguous = _validate_execution_status_roles(
|
|
473
474
|
data, analysis_worker_roles, errors
|
|
@@ -235,8 +235,24 @@ def _worker_role(value: str) -> str | None:
|
|
|
235
235
|
|
|
236
236
|
|
|
237
237
|
def _worker_roles(worker: dict) -> set[str]:
|
|
238
|
-
role
|
|
239
|
-
|
|
238
|
+
"""명단 `role` 과 `workerId` 를 활동 `agent` 가 쓸 수 있는 별칭으로 펼친다.
|
|
239
|
+
|
|
240
|
+
활동은 `codex` / `codex-worker` 를 쓰고 명단은 `Codex worker` 를 쓴다.
|
|
241
|
+
역할 문자열만 보면 둘은 다른 사람이 된다.
|
|
242
|
+
"""
|
|
243
|
+
roles: set[str] = set()
|
|
244
|
+
for raw in (worker.get("role"), worker.get("workerId")):
|
|
245
|
+
role = _worker_role(str(raw or ""))
|
|
246
|
+
if not role:
|
|
247
|
+
continue
|
|
248
|
+
compact = role.replace(" ", "-")
|
|
249
|
+
roles.add(role)
|
|
250
|
+
roles.add(compact)
|
|
251
|
+
if role.endswith(" worker"):
|
|
252
|
+
roles.add(role[: -len(" worker")])
|
|
253
|
+
if compact.endswith("-worker"):
|
|
254
|
+
roles.add(compact[: -len("-worker")])
|
|
255
|
+
return roles
|
|
240
256
|
|
|
241
257
|
|
|
242
258
|
def _analysis_workers(team_state: Mapping[str, Any]) -> list[dict]:
|
|
@@ -1036,12 +1052,17 @@ def _check_activity_contract(
|
|
|
1036
1052
|
if not _is_activity_contract_v1_planning(run_manifest):
|
|
1037
1053
|
return
|
|
1038
1054
|
indexed = _activity_index(evidence.activities)
|
|
1055
|
+
workers = [
|
|
1056
|
+
worker
|
|
1057
|
+
for worker in (team_state.get("workers") or [])
|
|
1058
|
+
if isinstance(worker, dict)
|
|
1059
|
+
]
|
|
1039
1060
|
analysis_workers = _analysis_workers(team_state)
|
|
1040
1061
|
report_data = _read_report_data(report_path)
|
|
1041
1062
|
_check_activity_id_order(evidence.activities, errors)
|
|
1042
1063
|
_check_projected_agent_activity(evidence.activities, report_data, errors)
|
|
1043
1064
|
_check_activity_worker_pairs(evidence, indexed, analysis_workers, errors)
|
|
1044
|
-
_check_activity_worker_agents(indexed,
|
|
1065
|
+
_check_activity_worker_agents(indexed, workers, errors)
|
|
1045
1066
|
_check_activity_round_counts(indexed, report_data, run_dir, suffix, errors)
|
|
1046
1067
|
_check_activity_user_decisions(indexed, report_data, errors)
|
|
1047
1068
|
|