okstra 0.183.2 → 0.185.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli-registry.mjs +9 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.d.mts +1 -0
- package/dist/commands/chat/chat.mjs +385 -0
- package/dist/commands/chat/chat.mjs.map +1 -0
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/docs/architecture.md +10 -8
- package/docs/cli.md +9 -5
- package/docs/for-ai/README.md +4 -2
- package/docs/for-ai/skills/okstra-chat.md +28 -0
- package/docs/for-ai/skills/okstra-inspect.md +1 -1
- package/docs/for-ai/skills/okstra-run.md +2 -2
- package/docs/for-ai/skills/okstra-user-response.md +10 -8
- package/docs/project-structure-overview.md +6 -5
- package/docs/task-process/README.md +2 -2
- package/docs/task-process/common-flow.md +2 -3
- package/docs/task-process/error-analysis.md +3 -4
- package/docs/task-process/final-verification.md +2 -3
- package/docs/task-process/implementation-planning.md +3 -4
- package/docs/task-process/implementation.md +2 -3
- package/docs/task-process/release-handoff.md +3 -4
- package/docs/task-process/requirements-discovery.md +3 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +8 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
- package/runtime/prompts/lead/plan-body-verification.md +27 -19
- package/runtime/prompts/lead/report-writer.md +4 -4
- package/runtime/prompts/profiles/_clarification-recommendation.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +1 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +11 -12
- package/runtime/prompts/wizard/prompts.ko.json +9 -10
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/conformance.py +37 -1
- package/runtime/python/okstra_ctl/incremental_scope.py +84 -39
- package/runtime/python/okstra_ctl/next_phase.py +67 -4
- package/runtime/python/okstra_ctl/plan_items.py +410 -1
- package/runtime/python/okstra_ctl/plan_items_cli.py +346 -31
- package/runtime/python/okstra_ctl/render.py +4 -0
- package/runtime/python/okstra_ctl/user_response.py +147 -37
- package/runtime/python/okstra_ctl/wizard.py +52 -73
- package/runtime/schemas/final-report-v2.0.schema.json +12 -0
- package/runtime/schemas/final-report-v3.0.schema.json +12 -0
- package/runtime/skills/okstra-chat/SKILL.md +104 -0
- package/runtime/skills/okstra-inspect/facets/status.md +6 -5
- package/runtime/skills/okstra-run/SKILL.md +4 -4
- package/runtime/skills/okstra-user-response/SKILL.md +50 -16
- package/runtime/validators/validate-run.py +254 -81
- package/runtime/validators/validate_session_conformance.py +24 -5
|
@@ -28,6 +28,11 @@ CUTOFF_RATIO = 0.5
|
|
|
28
28
|
# either a structural judgement or a mapping the lead never made.
|
|
29
29
|
DECLARED_FULL_PREFIX = "declared structural change:"
|
|
30
30
|
|
|
31
|
+
# CLI-to-lead signal only: an answered id traced to no stage and the user has
|
|
32
|
+
# not named `--impacted` stages. Never recorded as incrementalDecision (schema
|
|
33
|
+
# enum is incremental|full). The lead asks for stage numbers and calls again.
|
|
34
|
+
UNRESOLVED_MODE = "unresolved"
|
|
35
|
+
|
|
31
36
|
# `P-Step-<stage>.<step>` and `P-Prep-S<stage>-<kind>` carry their stage in the
|
|
32
37
|
# id itself. Every other prefix (`P-Opt`, `P-Dep`, `P-Val`, `P-Rb`, `P-Req`) is
|
|
33
38
|
# numbered by position in its own array, so its stage is only recoverable from
|
|
@@ -37,7 +42,7 @@ _STRUCTURAL_STAGE_IN_ID_RE = re.compile(r"^P-(?:Step-(\d+)\.\d+|Prep-S(\d+)-)")
|
|
|
37
42
|
|
|
38
43
|
@dataclass
|
|
39
44
|
class IncrementalDecision:
|
|
40
|
-
mode: str # "incremental" | "full"
|
|
45
|
+
mode: str # "incremental" | "full" | "unresolved"
|
|
41
46
|
reverify_stages: list[int]
|
|
42
47
|
carry_stages: list[int]
|
|
43
48
|
reason: str
|
|
@@ -167,6 +172,25 @@ def _stages_blocked_on(coverage: object, clarification_id: str) -> set[int]:
|
|
|
167
172
|
return stages
|
|
168
173
|
|
|
169
174
|
|
|
175
|
+
def partition_clarification_stages(
|
|
176
|
+
data: dict, clarification_ids: set[str]
|
|
177
|
+
) -> tuple[set[int], list[str]]:
|
|
178
|
+
"""연결된 stage 번호와, 아무 stage 에도 안 닿는 id.
|
|
179
|
+
|
|
180
|
+
preview 와 판정 경로가 같은 분할을 쓴다. unlinked 는 full 강등이 아니라
|
|
181
|
+
`--impacted` 를 받거나 `--full-reason` 을 받는 분기이다.
|
|
182
|
+
"""
|
|
183
|
+
impacted: set[int] = set()
|
|
184
|
+
unlinked: list[str] = []
|
|
185
|
+
for clarification_id in sorted(clarification_ids):
|
|
186
|
+
stages = stages_for_clarification(data, clarification_id)
|
|
187
|
+
if stages:
|
|
188
|
+
impacted |= stages
|
|
189
|
+
else:
|
|
190
|
+
unlinked.append(clarification_id)
|
|
191
|
+
return impacted, unlinked
|
|
192
|
+
|
|
193
|
+
|
|
170
194
|
def clarification_impacted_stages(
|
|
171
195
|
data: dict, clarification_ids: set[str]
|
|
172
196
|
) -> set[int]:
|
|
@@ -179,16 +203,15 @@ def clarification_impacted_stages(
|
|
|
179
203
|
|
|
180
204
|
Raises when any id resolves to no stage — a partially-resolved set would
|
|
181
205
|
narrow the re-run past an answer whose blast radius nobody established.
|
|
206
|
+
The CLI decision path does not use this raise as a full fallback; it asks
|
|
207
|
+
for `--impacted` instead.
|
|
182
208
|
"""
|
|
183
|
-
impacted
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
"(no plan item or coverage row cites one)"
|
|
190
|
-
)
|
|
191
|
-
impacted |= stages
|
|
209
|
+
impacted, unlinked = partition_clarification_stages(data, clarification_ids)
|
|
210
|
+
if unlinked:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"answered clarification {unlinked[0]} traces to no stage "
|
|
213
|
+
"(no plan item or coverage row cites one)"
|
|
214
|
+
)
|
|
192
215
|
return impacted
|
|
193
216
|
|
|
194
217
|
|
|
@@ -227,20 +250,19 @@ def preview_link_availability(data: dict, clarification_ids: set[str]) -> dict:
|
|
|
227
250
|
be shown while the run is still reshapeable.
|
|
228
251
|
|
|
229
252
|
`wouldForceFull: false` is therefore not a promise of `incremental` — it
|
|
230
|
-
says only that this half found nothing forcing full.
|
|
253
|
+
says only that this half found nothing forcing full. An unlinked id is
|
|
254
|
+
not such a thing: it cannot auto-narrow, and it does not force full.
|
|
231
255
|
"""
|
|
232
|
-
unlinked =
|
|
233
|
-
clarification_id
|
|
234
|
-
for clarification_id in clarification_ids
|
|
235
|
-
if not stages_for_clarification(data, clarification_id)
|
|
236
|
-
)
|
|
256
|
+
_, unlinked = partition_clarification_stages(data, clarification_ids)
|
|
237
257
|
if unlinked:
|
|
238
258
|
return {
|
|
239
|
-
"wouldForceFull":
|
|
259
|
+
"wouldForceFull": False,
|
|
240
260
|
"unlinkedIds": unlinked,
|
|
241
261
|
"reason": (
|
|
242
262
|
f"{', '.join(unlinked)} trace(s) to no stage in the prior report — "
|
|
243
|
-
"
|
|
263
|
+
"name those stages via --impacted, or declare a structural change "
|
|
264
|
+
"with --full-reason; an unlinked id does not force the whole re-run "
|
|
265
|
+
"to full"
|
|
244
266
|
),
|
|
245
267
|
}
|
|
246
268
|
if not clarification_ids:
|
|
@@ -350,6 +372,47 @@ def _preview_result(args) -> dict:
|
|
|
350
372
|
}
|
|
351
373
|
|
|
352
374
|
|
|
375
|
+
def _decision_for_run(args) -> IncrementalDecision:
|
|
376
|
+
"""SHA·폐포·컷오프 판정. unlinked 는 full 로 강등하지 않는다."""
|
|
377
|
+
data = load_owned_object(
|
|
378
|
+
Path(args.prev_data), artifact="incremental scope report"
|
|
379
|
+
)
|
|
380
|
+
stages = parse_stage_graph(data)
|
|
381
|
+
impacted = {
|
|
382
|
+
int(token.strip()) for token in args.impacted.split(",") if token.strip()
|
|
383
|
+
}
|
|
384
|
+
prep_ids = {
|
|
385
|
+
token.strip() for token in args.prep_items.split(",") if token.strip()
|
|
386
|
+
}
|
|
387
|
+
impacted.update(design_prep_impacted_stages(data, prep_ids))
|
|
388
|
+
answered = {
|
|
389
|
+
token.strip()
|
|
390
|
+
for token in args.answered_clarifications.split(",")
|
|
391
|
+
if token.strip()
|
|
392
|
+
}
|
|
393
|
+
linked, unlinked = partition_clarification_stages(data, answered)
|
|
394
|
+
if unlinked and not impacted:
|
|
395
|
+
return IncrementalDecision(
|
|
396
|
+
UNRESOLVED_MODE,
|
|
397
|
+
[],
|
|
398
|
+
[],
|
|
399
|
+
f"{', '.join(unlinked)} trace(s) to no stage in the prior report — "
|
|
400
|
+
"pass --impacted with the stage numbers those answers affect, or "
|
|
401
|
+
"--full-reason for a structural change",
|
|
402
|
+
)
|
|
403
|
+
impacted.update(linked)
|
|
404
|
+
unknown_stages = impacted - {stage for stage, _ in stages}
|
|
405
|
+
if unknown_stages:
|
|
406
|
+
unknown = ", ".join(str(stage) for stage in sorted(unknown_stages))
|
|
407
|
+
raise ValueError(f"impacted stage(s) absent from Stage Map: {unknown}")
|
|
408
|
+
return decide_scope(
|
|
409
|
+
stages=stages,
|
|
410
|
+
impacted_stages=impacted,
|
|
411
|
+
prev_base_sha=args.prev_base_sha,
|
|
412
|
+
cur_base_sha=args.cur_base_sha,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
353
416
|
def main(argv: list[str]) -> int:
|
|
354
417
|
ap = argparse.ArgumentParser(prog="okstra incremental-scope")
|
|
355
418
|
ap.add_argument("--prev-data", required=True, help="prior run final-report data.json")
|
|
@@ -395,31 +458,13 @@ def main(argv: list[str]) -> int:
|
|
|
395
458
|
return 0
|
|
396
459
|
|
|
397
460
|
try:
|
|
398
|
-
|
|
399
|
-
Path(args.prev_data), artifact="incremental scope report"
|
|
400
|
-
)
|
|
401
|
-
stages = parse_stage_graph(data)
|
|
402
|
-
impacted = {int(t.strip()) for t in args.impacted.split(",") if t.strip()}
|
|
403
|
-
prep_ids = {t.strip() for t in args.prep_items.split(",") if t.strip()}
|
|
404
|
-
impacted.update(design_prep_impacted_stages(data, prep_ids))
|
|
405
|
-
answered = {
|
|
406
|
-
t.strip() for t in args.answered_clarifications.split(",") if t.strip()
|
|
407
|
-
}
|
|
408
|
-
if answered:
|
|
409
|
-
impacted.update(clarification_impacted_stages(data, answered))
|
|
410
|
-
unknown_stages = impacted - {stage for stage, _ in stages}
|
|
411
|
-
if unknown_stages:
|
|
412
|
-
unknown = ", ".join(str(stage) for stage in sorted(unknown_stages))
|
|
413
|
-
raise ValueError(f"impacted stage(s) absent from Stage Map: {unknown}")
|
|
414
|
-
decision = decide_scope(
|
|
415
|
-
stages=stages, impacted_stages=impacted,
|
|
416
|
-
prev_base_sha=args.prev_base_sha, cur_base_sha=args.cur_base_sha,
|
|
417
|
-
)
|
|
461
|
+
decision = _decision_for_run(args)
|
|
418
462
|
# Every bad input degrades to a full re-verification rather than raising:
|
|
419
463
|
# `full` is always the safe answer, and the caller reads the decision off
|
|
420
464
|
# stdout, so a traceback would leave it with no decision at all. OSError
|
|
421
465
|
# covers an unreadable --prev-data, KeyError/TypeError a stageMap row that
|
|
422
|
-
# is missing `stage` or is not a mapping.
|
|
466
|
+
# is missing `stage` or is not a mapping. Unlinked ids are not this case
|
|
467
|
+
# — `_decision_for_run` returns `unresolved` instead of raising.
|
|
423
468
|
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
424
469
|
decision = IncrementalDecision(
|
|
425
470
|
"full", [], [], f"invalid incremental-scope input: {exc}",
|
|
@@ -9,6 +9,8 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import Any, Mapping
|
|
11
11
|
|
|
12
|
+
from okstra_ctl.clarification_items import APPROVAL_BLOCKS, UNRESOLVED_STATUSES
|
|
13
|
+
|
|
12
14
|
STATUS_READY = "ready"
|
|
13
15
|
STATUS_PENDING = "pending"
|
|
14
16
|
STATUS_BLOCKED = "blocked"
|
|
@@ -69,6 +71,12 @@ _OPTION_SELECTION_NON_PHASE = {
|
|
|
69
71
|
"blocked": STATUS_BLOCKED,
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
# run.py BLOCKING_PLAN_BODY_GATES 와 같아야 한다. next_phase 는 run 을
|
|
75
|
+
# 가져오지 않는다 — wizard 가 둘 다 import 해서 순환이 생긴다.
|
|
76
|
+
_BLOCKING_PLAN_GATES = frozenset(
|
|
77
|
+
{"blocked-by-disagreement", "aborted-non-result"}
|
|
78
|
+
)
|
|
79
|
+
|
|
72
80
|
# final-verification 의 routing enum 중 phase 이름이 아니라 phase 에 붙은 범위
|
|
73
81
|
# 한정자인 값 → 실제로 실행할 phase. `release-handoff(stage-group)` 은 넘길 stage
|
|
74
82
|
# 묶음을 좁힌다는 뜻이지 다른 phase 가 아니다. 범위는 위저드의 handoff_stage_pick
|
|
@@ -210,13 +218,68 @@ def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
|
|
|
210
218
|
return make(phase=routing, status=STATUS_READY)
|
|
211
219
|
|
|
212
220
|
|
|
221
|
+
def _unresolved_approval_ids(report_data: Mapping[str, Any]) -> list[str]:
|
|
222
|
+
rows = report_data.get("clarificationItems")
|
|
223
|
+
if not isinstance(rows, list):
|
|
224
|
+
return []
|
|
225
|
+
ids: list[str] = []
|
|
226
|
+
for row in rows:
|
|
227
|
+
if not isinstance(row, Mapping):
|
|
228
|
+
continue
|
|
229
|
+
blocks = str(row.get("blocks") or "").strip().lower()
|
|
230
|
+
status = str(row.get("status") or "").strip().lower()
|
|
231
|
+
row_id = row.get("id")
|
|
232
|
+
if (
|
|
233
|
+
blocks in APPROVAL_BLOCKS
|
|
234
|
+
and status in UNRESOLVED_STATUSES
|
|
235
|
+
and isinstance(row_id, str)
|
|
236
|
+
and row_id
|
|
237
|
+
):
|
|
238
|
+
ids.append(row_id)
|
|
239
|
+
return ids
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _planning_approval_block_reason(
|
|
243
|
+
report_data: Mapping[str, Any], planning: Mapping[str, Any]
|
|
244
|
+
) -> str:
|
|
245
|
+
"""plan-ready 인데 승인할 수 없으면 근거, 아니면 빈 문자열.
|
|
246
|
+
|
|
247
|
+
자문 게이트(`passed-with-dissent`)와 재현 실패 `has-dissent` 는 여기 안
|
|
248
|
+
들어온다. 차단은 `blocked-by-disagreement` / `aborted-non-result` 와
|
|
249
|
+
`Status` 가 open/answered 인 `Blocks=approval` 행뿐이다.
|
|
250
|
+
"""
|
|
251
|
+
ids = _unresolved_approval_ids(report_data)
|
|
252
|
+
if ids:
|
|
253
|
+
listed = ", ".join(ids)
|
|
254
|
+
return (
|
|
255
|
+
f"{listed} 가 Blocks=approval 로 열려 승인할 수 없습니다. "
|
|
256
|
+
"okstra-user-response 로 답한 뒤 그 답을 가지고 계획 단계를 "
|
|
257
|
+
"재개하세요. 구현을 시작하거나, 답을 쓰기 전에 계획 단계를 "
|
|
258
|
+
"다시 돌리지 마세요."
|
|
259
|
+
)
|
|
260
|
+
verification = planning.get("planBodyVerification")
|
|
261
|
+
gate = ""
|
|
262
|
+
if isinstance(verification, Mapping):
|
|
263
|
+
gate = str(verification.get("gateResult") or "").strip().lower()
|
|
264
|
+
if gate in _BLOCKING_PLAN_GATES:
|
|
265
|
+
return (
|
|
266
|
+
f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
|
|
267
|
+
"구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
|
|
268
|
+
)
|
|
269
|
+
return ""
|
|
270
|
+
|
|
271
|
+
|
|
213
272
|
def _from_planning(report_data: Mapping[str, Any]) -> dict[str, str]:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return make(phase="implementation", status=STATUS_READY)
|
|
273
|
+
planning = _block(report_data, "implementationPlanning")
|
|
274
|
+
outcome = str(planning.get("outcome") or "")
|
|
217
275
|
if outcome == "direction-invalidated":
|
|
218
276
|
return make(phase="implementation-option-selection", status=STATUS_READY)
|
|
219
|
-
|
|
277
|
+
if outcome != "plan-ready":
|
|
278
|
+
return make(status=STATUS_PENDING)
|
|
279
|
+
blocked_reason = _planning_approval_block_reason(report_data, planning)
|
|
280
|
+
if blocked_reason:
|
|
281
|
+
return make(status=STATUS_BLOCKED, rationale=blocked_reason)
|
|
282
|
+
return make(phase="implementation", status=STATUS_READY)
|
|
220
283
|
|
|
221
284
|
|
|
222
285
|
def _from_target(report_data: Mapping[str, Any], key: str) -> dict[str, str]:
|