okstra 0.158.1 → 0.160.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/architecture/storage-model.md +2 -0
- package/docs/architecture.md +1 -1
- package/docs/cli.md +8 -3
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-inspect.md +3 -0
- package/docs/for-ai/skills/okstra-run.md +2 -1
- package/docs/for-ai/skills/okstra-user-response.md +5 -5
- package/docs/project-structure-overview.md +5 -1
- package/docs/task-process/implementation.md +28 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-claude-exec.sh +4 -1
- package/runtime/prompts/host-orchestration/README.md +18 -0
- package/runtime/prompts/host-orchestration/implementation.md +57 -0
- package/runtime/prompts/launch.template.md +10 -1
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/context-loader.md +5 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/plan-body-verification.md +21 -2
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +2 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +3 -0
- package/runtime/python/okstra_ctl/clarification_items.py +9 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
- package/runtime/python/okstra_ctl/convergence.py +168 -11
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
- package/runtime/python/okstra_ctl/error_issue.py +640 -0
- package/runtime/python/okstra_ctl/error_report.py +56 -0
- package/runtime/python/okstra_ctl/error_zip.py +23 -10
- package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
- package/runtime/python/okstra_ctl/issue_signals.py +186 -0
- package/runtime/python/okstra_ctl/paths.py +38 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
- package/runtime/python/okstra_ctl/profile_show.py +134 -0
- package/runtime/python/okstra_ctl/recap.py +63 -0
- package/runtime/python/okstra_ctl/render_final_report.py +11 -62
- package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
- package/runtime/python/okstra_ctl/report_html/render.py +9 -8
- package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
- package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
- package/runtime/python/okstra_ctl/report_translation.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +7 -3
- package/runtime/python/okstra_ctl/run.py +41 -2
- package/runtime/python/okstra_ctl/run_audit.py +477 -0
- package/runtime/python/okstra_ctl/usage_cells.py +47 -0
- package/runtime/python/okstra_ctl/user_response.py +25 -10
- package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
- package/runtime/python/okstra_ctl/wizard.py +64 -10
- package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
- package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
- package/runtime/schemas/final-report-v1.0.schema.json +14 -0
- package/runtime/schemas/final-report-v2.0.schema.json +56 -2
- package/runtime/skills/okstra-inspect/SKILL.md +3 -1
- package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
- package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
- package/runtime/skills/okstra-run/SKILL.md +28 -10
- package/runtime/skills/okstra-user-response/SKILL.md +18 -18
- package/runtime/templates/reports/final-report.template.md +4 -0
- package/runtime/templates/reports/html/assets/base.css +14 -1
- package/runtime/templates/reports/html/base.template.html +42 -0
- package/runtime/templates/reports/html/i18n/en.json +30 -1
- package/runtime/templates/reports/html/i18n/ko.json +30 -1
- package/runtime/templates/reports/html/macros/forms.html +15 -0
- package/runtime/templates/reports/html/macros/visualizations.html +3 -2
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
- package/runtime/templates/reports/i18n/en.json +2 -0
- package/runtime/validators/validate-run.py +331 -208
- package/runtime/validators/validate_session_conformance.py +102 -32
- package/src/cli-registry.mjs +34 -0
- package/src/commands/execute/incremental-scope.mjs +10 -0
- package/src/commands/execute/worker-audit-check.mjs +35 -0
- package/src/commands/inspect/error-issue.mjs +27 -0
- package/src/commands/inspect/profile-show.mjs +29 -0
- package/src/commands/inspect/run-audit.mjs +26 -0
|
@@ -35,6 +35,62 @@ def _md_table(header: list[str], rows: list[list[str]]) -> str:
|
|
|
35
35
|
return "\n".join([line, sep, *body])
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
# The two types a next run can actually act on: a worker that broke its
|
|
39
|
+
# contract (adjust the roster or pre-check its result) and a tool that failed
|
|
40
|
+
# (raise a budget, or expect the same failure). Everything else is either
|
|
41
|
+
# already retried or not actionable at preparation time.
|
|
42
|
+
CARRY_FORWARD_ERROR_TYPES = ("contract-violation", "tool-failure")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def prior_run_error_digest(
|
|
46
|
+
task_root: Path, *, error_types: tuple[str, ...] = CARRY_FORWARD_ERROR_TYPES
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Markdown digest of the traps this task's earlier runs already hit.
|
|
49
|
+
|
|
50
|
+
Read-only: unlike ``build_and_write`` this writes no report file, so it is
|
|
51
|
+
safe to call while a run is being prepared. The records have always been on
|
|
52
|
+
disk under ``runs/*/logs/errors-*.jsonl`` and ``okstra error-report`` has
|
|
53
|
+
aggregated them for just as long — what was missing is anyone reading them
|
|
54
|
+
*before* the next run, so the same violation recurred and the response was
|
|
55
|
+
improvised every time.
|
|
56
|
+
|
|
57
|
+
Returns ``""`` when nothing actionable is recorded, which is the signal for
|
|
58
|
+
the caller to stage no file at all rather than an empty one.
|
|
59
|
+
"""
|
|
60
|
+
records, _ = parse_records(glob_error_logs(task_root))
|
|
61
|
+
carried = [
|
|
62
|
+
record
|
|
63
|
+
for record in records
|
|
64
|
+
if str(record.get("errorType", "")) in error_types
|
|
65
|
+
]
|
|
66
|
+
if not carried:
|
|
67
|
+
return ""
|
|
68
|
+
counts: dict[tuple[str, str, str], int] = {}
|
|
69
|
+
for record in carried:
|
|
70
|
+
key = (
|
|
71
|
+
str(record.get("errorType", "")),
|
|
72
|
+
str(record.get("phase", "")),
|
|
73
|
+
str(record.get("agent", "")),
|
|
74
|
+
)
|
|
75
|
+
counts[key] = counts.get(key, 0) + 1
|
|
76
|
+
table = _md_table(
|
|
77
|
+
["Error type", "Phase", "Agent", "Count"],
|
|
78
|
+
[
|
|
79
|
+
[error_type, phase or "—", agent or "—", str(count)]
|
|
80
|
+
for (error_type, phase, agent), count in sorted(
|
|
81
|
+
counts.items(), key=lambda item: (-item[1], item[0])
|
|
82
|
+
)
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
return (
|
|
86
|
+
"# Prior-Run Errors\n\n"
|
|
87
|
+
"`contract-violation` and `tool-failure` records this task's earlier "
|
|
88
|
+
"runs wrote. These are not findings about the work — they are traps "
|
|
89
|
+
"that fired before and can fire again in this run.\n\n"
|
|
90
|
+
f"{table}\n"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
38
94
|
def render_markdown(*, task_key, records, agg, parse_skipped, generated_at) -> str:
|
|
39
95
|
by_type = ", ".join(f"{k}: {v}" for k, v in sorted(agg["byErrorType"].items())) or "_없음_"
|
|
40
96
|
by_src = ", ".join(f"{k}: {v}" for k, v in sorted(agg["bySource"].items())) or "_없음_"
|
|
@@ -19,10 +19,16 @@ from okstra_ctl.locks import central_lock
|
|
|
19
19
|
from okstra_ctl.paths import okstra_home, resolve_under_root
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def
|
|
22
|
+
def run_dirs(home: Path) -> list[tuple[str, Path, dict]]:
|
|
23
|
+
"""글로벌 run-index 순회의 단일 참조점 — `(project_root, run_dir, row)`.
|
|
24
|
+
|
|
25
|
+
인덱스 행을 함께 돌려주는 이유: 행에는 `status` 처럼 디스크만 봐서는 알 수
|
|
26
|
+
없는 사실이 실려 있다(`run_index_row.build_run_index_row`). 행을 버리면 그게
|
|
27
|
+
필요한 소비자가 recent/active 순회를 통째로 복제하게 된다.
|
|
28
|
+
"""
|
|
23
29
|
rows = read_jsonl(home / "recent.jsonl") + read_jsonl(home / "active.jsonl")
|
|
24
30
|
seen: set[tuple[str, str]] = set()
|
|
25
|
-
out: list[tuple[str, Path]] = []
|
|
31
|
+
out: list[tuple[str, Path, dict]] = []
|
|
26
32
|
for row in rows:
|
|
27
33
|
project_root = str(row.get("projectRoot", ""))
|
|
28
34
|
run_dir_rel = str(row.get("runDirRel", ""))
|
|
@@ -32,7 +38,7 @@ def _run_dirs(home: Path) -> list[tuple[str, Path]]:
|
|
|
32
38
|
seen.add(key)
|
|
33
39
|
run_dir = resolve_under_root(project_root, run_dir_rel)
|
|
34
40
|
if run_dir is not None:
|
|
35
|
-
out.append((project_root, run_dir))
|
|
41
|
+
out.append((project_root, run_dir, row))
|
|
36
42
|
return out
|
|
37
43
|
|
|
38
44
|
|
|
@@ -48,7 +54,7 @@ def collect_records(home: Path) -> tuple[list[dict], dict]:
|
|
|
48
54
|
project_roots: set[str] = set()
|
|
49
55
|
reachable = 0
|
|
50
56
|
unreachable = 0
|
|
51
|
-
for project_root, run_dir in
|
|
57
|
+
for project_root, run_dir, _row in run_dirs(home):
|
|
52
58
|
if not run_dir.exists():
|
|
53
59
|
unreachable += 1
|
|
54
60
|
continue
|
|
@@ -169,9 +175,12 @@ def anonymize(records: list[dict]) -> tuple[list[dict], dict]:
|
|
|
169
175
|
continue
|
|
170
176
|
scrub = _scrub_freetext if k in _FREETEXT_FIELDS else _scrub
|
|
171
177
|
item[k] = scrub(rec.get(k), token_map)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
178
|
+
if "taskKey" in item:
|
|
179
|
+
# 프로젝트 접두만 토큰화하고 나머지를 남기면 타겟의 티켓 번호가
|
|
180
|
+
# 그대로 나간다 — `proj-2:DEV-9388:DEV-9428` 은 익명이 아니다.
|
|
181
|
+
# 클러스터를 식별하는 것은 이미 지문이므로, 태스크 아이디는 공개
|
|
182
|
+
# 이슈나 공유 zip 을 읽는 사람에게 아무것도 더 주지 않는다.
|
|
183
|
+
item["taskKey"] = token
|
|
175
184
|
item["sourceProject"] = token
|
|
176
185
|
clean.append(item)
|
|
177
186
|
return clean, token_map
|
|
@@ -193,9 +202,13 @@ def cluster_key(rec: dict) -> str:
|
|
|
193
202
|
])
|
|
194
203
|
|
|
195
204
|
|
|
196
|
-
def build_clusters(records: list[dict]
|
|
205
|
+
def build_clusters(records: list[dict], *,
|
|
206
|
+
project_field: str = "sourceProject") -> tuple[list[dict], list[str]]:
|
|
197
207
|
"""클러스터 목록과, records 와 같은 순서의 레코드별 cluster_key 를 함께 반환.
|
|
198
|
-
호출자가 직렬화 시 키를 재계산(정규식 중복)하지 않도록 키를 노출한다.
|
|
208
|
+
호출자가 직렬화 시 키를 재계산(정규식 중복)하지 않도록 키를 노출한다.
|
|
209
|
+
|
|
210
|
+
project_field 는 확산을 세는 소스다. 익명화 레코드는 anonymize() 가 붙인
|
|
211
|
+
`sourceProject`, 원본 레코드는 collect_records() 가 붙인 `_projectRoot` 를 쓴다."""
|
|
199
212
|
buckets: dict[str, dict] = {}
|
|
200
213
|
keys: list[str] = []
|
|
201
214
|
for rec in records:
|
|
@@ -207,7 +220,7 @@ def build_clusters(records: list[dict]) -> tuple[list[dict], list[str]]:
|
|
|
207
220
|
"count": 0, "projects": set(), "sample": str(rec.get("message", "")),
|
|
208
221
|
})
|
|
209
222
|
b["count"] += 1
|
|
210
|
-
b["projects"].add(str(rec.get(
|
|
223
|
+
b["projects"].add(str(rec.get(project_field, "")))
|
|
211
224
|
clusters = []
|
|
212
225
|
for b in buckets.values():
|
|
213
226
|
b["projects"] = sorted(p for p in b["projects"] if p)
|
|
@@ -15,6 +15,7 @@ import sys
|
|
|
15
15
|
from dataclasses import asdict, dataclass
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
|
+
from okstra_ctl.final_report_paths import final_report_data_path
|
|
18
19
|
from okstra_ctl.stage_citations import cited_stage_numbers
|
|
19
20
|
from okstra_ctl.stage_targets import downstream_stage_closure
|
|
20
21
|
|
|
@@ -94,15 +95,37 @@ def _plan_item_stages(item: dict) -> set[int]:
|
|
|
94
95
|
return cited_stage_numbers(str(item.get("subject") or ""))
|
|
95
96
|
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
_BLOCK_MARKER_FIELDS = ("status", "approvalDisposition")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def coverage_row_blocked_on(row: object, clarification_id: str) -> bool:
|
|
102
|
+
"""Whether a requirement-coverage row records a block on this clarification.
|
|
103
|
+
|
|
104
|
+
The marker lives in either of two fields, and the schema says so: `status`
|
|
105
|
+
allows `blocked C-NNN`, and so does `approvalDisposition`. A row recorded
|
|
106
|
+
as a `documented-deviation` carries its block in the latter, so reading
|
|
107
|
+
`status` alone missed it — which forced the whole re-run to full for an
|
|
108
|
+
answer whose blast radius the report did record.
|
|
109
|
+
|
|
110
|
+
`validate-run.py` shares this predicate to require that every approval
|
|
111
|
+
blocker has at least one such link. One definition, so the gate cannot
|
|
112
|
+
demand a link shape this resolver would refuse to follow.
|
|
113
|
+
"""
|
|
114
|
+
if not isinstance(row, dict):
|
|
115
|
+
return False
|
|
98
116
|
blocked = f"blocked {clarification_id}"
|
|
117
|
+
return any(
|
|
118
|
+
str(row.get(field) or "").strip() == blocked
|
|
119
|
+
for field in _BLOCK_MARKER_FIELDS
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _stages_blocked_on(coverage: object, clarification_id: str) -> set[int]:
|
|
124
|
+
"""Stages of every coverage row blocked on ``clarification_id``."""
|
|
99
125
|
stages: set[int] = set()
|
|
100
126
|
for row in coverage if isinstance(coverage, list) else []:
|
|
101
|
-
if
|
|
102
|
-
|
|
103
|
-
if str(row.get("status") or "").strip() != blocked:
|
|
104
|
-
continue
|
|
105
|
-
stages |= cited_stage_numbers(str(row.get("coveredBy") or ""))
|
|
127
|
+
if coverage_row_blocked_on(row, clarification_id):
|
|
128
|
+
stages |= cited_stage_numbers(str(row.get("coveredBy") or ""))
|
|
106
129
|
return stages
|
|
107
130
|
|
|
108
131
|
|
|
@@ -119,19 +142,9 @@ def clarification_impacted_stages(
|
|
|
119
142
|
Raises when any id resolves to no stage — a partially-resolved set would
|
|
120
143
|
narrow the re-run past an answer whose blast radius nobody established.
|
|
121
144
|
"""
|
|
122
|
-
planning = data.get("implementationPlanning")
|
|
123
|
-
if not isinstance(planning, dict):
|
|
124
|
-
raise ValueError("implementationPlanning is missing")
|
|
125
|
-
verification = planning.get("planBodyVerification")
|
|
126
|
-
plan_items = (verification or {}).get("planItems") if isinstance(verification, dict) else []
|
|
127
|
-
coverage = planning.get("requirementCoverage")
|
|
128
|
-
|
|
129
145
|
impacted: set[int] = set()
|
|
130
146
|
for clarification_id in sorted(clarification_ids):
|
|
131
|
-
stages =
|
|
132
|
-
for item in plan_items if isinstance(plan_items, list) else []:
|
|
133
|
-
if isinstance(item, dict) and item.get("clarificationId") == clarification_id:
|
|
134
|
-
stages |= _plan_item_stages(item)
|
|
147
|
+
stages = stages_for_clarification(data, clarification_id)
|
|
135
148
|
if not stages:
|
|
136
149
|
raise ValueError(
|
|
137
150
|
f"answered clarification {clarification_id} traces to no stage "
|
|
@@ -141,6 +154,97 @@ def clarification_impacted_stages(
|
|
|
141
154
|
return impacted
|
|
142
155
|
|
|
143
156
|
|
|
157
|
+
def stages_for_clarification(data: dict, clarification_id: str) -> set[int]:
|
|
158
|
+
"""Stages the prior run linked to one clarification; empty when it linked none.
|
|
159
|
+
|
|
160
|
+
Separate from the raising walk above because the preview needs the same
|
|
161
|
+
lookup without the exception: it reports *which* ids are unlinked, and a
|
|
162
|
+
raise on the first one would hide the rest.
|
|
163
|
+
"""
|
|
164
|
+
planning = data.get("implementationPlanning")
|
|
165
|
+
if not isinstance(planning, dict):
|
|
166
|
+
raise ValueError("implementationPlanning is missing")
|
|
167
|
+
verification = planning.get("planBodyVerification")
|
|
168
|
+
plan_items = (
|
|
169
|
+
verification.get("planItems") if isinstance(verification, dict) else []
|
|
170
|
+
)
|
|
171
|
+
stages = _stages_blocked_on(planning.get("requirementCoverage"), clarification_id)
|
|
172
|
+
for item in plan_items if isinstance(plan_items, list) else []:
|
|
173
|
+
if isinstance(item, dict) and item.get("clarificationId") == clarification_id:
|
|
174
|
+
stages |= _plan_item_stages(item)
|
|
175
|
+
return stages
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def preview_link_availability(data: dict, clarification_ids: set[str]) -> dict:
|
|
179
|
+
"""Would the answered ids let the next re-run narrow — decided without a SHA.
|
|
180
|
+
|
|
181
|
+
The full decision also needs safety condition C1 (base ref unchanged), and
|
|
182
|
+
the current base SHA only exists once `render-bundle` has written a
|
|
183
|
+
manifest and registered the run. By then the two-hour cost of a full
|
|
184
|
+
re-verification is already committed. Link availability is the half that
|
|
185
|
+
decides the common case and needs nothing but the prior report, so it can
|
|
186
|
+
be shown while the run is still reshapeable.
|
|
187
|
+
|
|
188
|
+
`wouldForceFull: false` is therefore not a promise of `incremental` — it
|
|
189
|
+
says only that this half found nothing forcing full.
|
|
190
|
+
"""
|
|
191
|
+
unlinked = sorted(
|
|
192
|
+
clarification_id
|
|
193
|
+
for clarification_id in clarification_ids
|
|
194
|
+
if not stages_for_clarification(data, clarification_id)
|
|
195
|
+
)
|
|
196
|
+
if unlinked:
|
|
197
|
+
return {
|
|
198
|
+
"wouldForceFull": True,
|
|
199
|
+
"unlinkedIds": unlinked,
|
|
200
|
+
"reason": (
|
|
201
|
+
f"{', '.join(unlinked)} trace(s) to no stage in the prior report — "
|
|
202
|
+
"an answer whose blast radius was never recorded cannot narrow anything"
|
|
203
|
+
),
|
|
204
|
+
}
|
|
205
|
+
if not clarification_ids:
|
|
206
|
+
return {
|
|
207
|
+
"wouldForceFull": True,
|
|
208
|
+
"unlinkedIds": [],
|
|
209
|
+
"reason": "no answered clarifications given; nothing to narrow with",
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
"wouldForceFull": False,
|
|
213
|
+
"unlinkedIds": [],
|
|
214
|
+
"reason": (
|
|
215
|
+
"every answered id traces to a stage; the base-ref comparison at run "
|
|
216
|
+
"time still decides the final mode"
|
|
217
|
+
),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def preview_link_availability_for_report(
|
|
222
|
+
report: Path, clarification_ids: set[str]
|
|
223
|
+
) -> dict:
|
|
224
|
+
"""`preview_link_availability` for a report on disk, data read included.
|
|
225
|
+
|
|
226
|
+
Both callers hold a report path, not a loaded dict. Leaving the read at
|
|
227
|
+
each call site is how they would drift on what a missing or unreadable
|
|
228
|
+
data sibling means — and that verdict is the one the user acts on.
|
|
229
|
+
"""
|
|
230
|
+
data_path = final_report_data_path(report)
|
|
231
|
+
if not data_path.is_file():
|
|
232
|
+
return {
|
|
233
|
+
"wouldForceFull": True,
|
|
234
|
+
"unlinkedIds": [],
|
|
235
|
+
"reason": f"prior report has no data sibling at {data_path.name}",
|
|
236
|
+
}
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads(data_path.read_text(encoding="utf-8"))
|
|
239
|
+
except (OSError, ValueError) as exc:
|
|
240
|
+
return {
|
|
241
|
+
"wouldForceFull": True,
|
|
242
|
+
"unlinkedIds": [],
|
|
243
|
+
"reason": f"prior report data is unreadable: {exc}",
|
|
244
|
+
}
|
|
245
|
+
return preview_link_availability(data, clarification_ids)
|
|
246
|
+
|
|
247
|
+
|
|
144
248
|
def decide_scope(
|
|
145
249
|
*,
|
|
146
250
|
stages: list[tuple[int, list[int]]],
|
|
@@ -168,11 +272,43 @@ def decide_scope(
|
|
|
168
272
|
return IncrementalDecision("incremental", reverify, carry, f"closure {reverify} within cutoff")
|
|
169
273
|
|
|
170
274
|
|
|
275
|
+
def _preview_result(args) -> dict:
|
|
276
|
+
"""`preview_link_availability` over CLI args, degrading to full on bad input.
|
|
277
|
+
|
|
278
|
+
Same posture as the decision path below: a caller reads the answer off
|
|
279
|
+
stdout, so a traceback would leave it with nothing. `full` is the safe
|
|
280
|
+
answer when the input cannot be read.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
data = json.loads(Path(args.prev_data).read_text(encoding="utf-8"))
|
|
284
|
+
answered = {
|
|
285
|
+
token.strip()
|
|
286
|
+
for token in args.answered_clarifications.split(",")
|
|
287
|
+
if token.strip()
|
|
288
|
+
}
|
|
289
|
+
return preview_link_availability(data, answered)
|
|
290
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
291
|
+
return {
|
|
292
|
+
"wouldForceFull": True,
|
|
293
|
+
"unlinkedIds": [],
|
|
294
|
+
"reason": f"invalid incremental-scope input: {exc}",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
171
298
|
def main(argv: list[str]) -> int:
|
|
172
299
|
ap = argparse.ArgumentParser(prog="okstra incremental-scope")
|
|
173
300
|
ap.add_argument("--prev-data", required=True, help="prior run final-report data.json")
|
|
174
|
-
|
|
175
|
-
|
|
301
|
+
# Not required: `--preview` answers the link half before a worktree base
|
|
302
|
+
# commit exists, which is the whole reason that mode is there.
|
|
303
|
+
ap.add_argument("--cur-base-sha", default="")
|
|
304
|
+
ap.add_argument("--prev-base-sha", default="")
|
|
305
|
+
ap.add_argument(
|
|
306
|
+
"--preview",
|
|
307
|
+
action="store_true",
|
|
308
|
+
help="report whether the answered ids could narrow the re-run, using the "
|
|
309
|
+
"prior report alone. Side-effect free and needs no base SHA, so it "
|
|
310
|
+
"can run before the decision to start is irreversible",
|
|
311
|
+
)
|
|
176
312
|
ap.add_argument("--impacted", default="", help="comma-separated impacted stage numbers")
|
|
177
313
|
ap.add_argument("--prep-items", default="", help="comma-separated changed PREP item IDs")
|
|
178
314
|
ap.add_argument(
|
|
@@ -190,6 +326,10 @@ def main(argv: list[str]) -> int:
|
|
|
190
326
|
)
|
|
191
327
|
args = ap.parse_args(argv)
|
|
192
328
|
|
|
329
|
+
if args.preview:
|
|
330
|
+
print(json.dumps(_preview_result(args), ensure_ascii=False))
|
|
331
|
+
return 0
|
|
332
|
+
|
|
193
333
|
declared = args.full_reason.strip()
|
|
194
334
|
if declared:
|
|
195
335
|
# The lead's structural judgement is exactly what the back-trace cannot
|
|
@@ -52,6 +52,14 @@ class InitialPromptMaterializationRequest:
|
|
|
52
52
|
delivery_mode: PromptDeliveryMode
|
|
53
53
|
workers: tuple[InitialPromptWorkerRequest, ...]
|
|
54
54
|
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
# A `str` in any of these surfaced as `'str' object has no attribute
|
|
57
|
+
# 'resolve'`, wrapped into `render_failed`, which named neither the
|
|
58
|
+
# field nor the caller. Coercing removes the failure rather than
|
|
59
|
+
# improving its message.
|
|
60
|
+
for field in ("project_root", "run_manifest_path", "runtime_root"):
|
|
61
|
+
object.__setattr__(self, field, Path(getattr(self, field)))
|
|
62
|
+
|
|
55
63
|
|
|
56
64
|
class InitialPromptMaterializationError(RuntimeError):
|
|
57
65
|
reason: MaterializationReason
|
|
@@ -63,7 +71,7 @@ class InitialPromptMaterializationError(RuntimeError):
|
|
|
63
71
|
|
|
64
72
|
MaterializeInitialPrompts = Callable[
|
|
65
73
|
[InitialPromptMaterializationRequest],
|
|
66
|
-
|
|
74
|
+
dict[str, Path],
|
|
67
75
|
]
|
|
68
76
|
|
|
69
77
|
_MATERIALIZABLE_AUDIENCES = frozenset({
|
|
@@ -124,8 +132,13 @@ class _PublishedPrompt:
|
|
|
124
132
|
|
|
125
133
|
def materialize_initial_prompts(
|
|
126
134
|
request: InitialPromptMaterializationRequest,
|
|
127
|
-
) ->
|
|
128
|
-
"""Materialize selected initial prompts
|
|
135
|
+
) -> dict[str, Path]:
|
|
136
|
+
"""Materialize the selected initial prompts, keyed by worker id.
|
|
137
|
+
|
|
138
|
+
A positional sequence made every caller re-pair prompts with workers by
|
|
139
|
+
index; a pairing that slips still type-checks and dispatches the wrong
|
|
140
|
+
prompt to the wrong worker.
|
|
141
|
+
"""
|
|
129
142
|
try:
|
|
130
143
|
return _materialize_initial_prompts(request)
|
|
131
144
|
except InitialPromptMaterializationError:
|
|
@@ -139,7 +152,7 @@ def materialize_initial_prompts(
|
|
|
139
152
|
|
|
140
153
|
def _materialize_initial_prompts(
|
|
141
154
|
request: InitialPromptMaterializationRequest,
|
|
142
|
-
) ->
|
|
155
|
+
) -> dict[str, Path]:
|
|
143
156
|
context = _load_materialization_context(request)
|
|
144
157
|
resolved_items = [
|
|
145
158
|
_resolve_prompt_item(context, worker)
|
|
@@ -157,7 +170,7 @@ def _materialize_initial_prompts(
|
|
|
157
170
|
)
|
|
158
171
|
_validate_published_set(context, published)
|
|
159
172
|
completed = True
|
|
160
|
-
return
|
|
173
|
+
return {prompt.worker.worker_id: prompt.path for prompt in published}
|
|
161
174
|
finally:
|
|
162
175
|
cleanup_error = _remove_temp_files(items)
|
|
163
176
|
if completed and cleanup_error is not None:
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""이슈 판정 신호 — 원본(익명화 전) 레코드에서만 계산되는 순수 함수.
|
|
2
|
+
|
|
3
|
+
익명화는 message/stderrExcerpt/command 의 경로를 <path> 로 붕괴시키므로
|
|
4
|
+
(error_zip._PATHISH), 경로 기반 신호는 익명화 뒤에는 계산할 수 없다.
|
|
5
|
+
신호 값 자체는 불리언·카운트라 공개 이슈에 실어도 안전하다.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
OKSTRA_DEFECT = "okstra-defect"
|
|
10
|
+
ENVIRONMENT_POLICY = "environment-policy"
|
|
11
|
+
TARGET_CODE = "target-code"
|
|
12
|
+
|
|
13
|
+
# okstra-defect 판정에 필요한 강한 지지 신호 수. 한 신호만으로는 공개 이슈를
|
|
14
|
+
# 만들지 않는다.
|
|
15
|
+
MIN_SUPPORTING_SIGNALS = 2
|
|
16
|
+
|
|
17
|
+
# 단일 phase 클러스터면 무조건 켜지므로 판정을 지탱하지 못한다. 근거로 보고하되
|
|
18
|
+
# 최소 근거 수에는 세지 않는다 — 세면 "근거 2개"가 "진짜 근거 1개 + 공짜 한 자리"가 된다.
|
|
19
|
+
WEAK_SIGNALS = ("phaseLock",)
|
|
20
|
+
|
|
21
|
+
# 리드가 규칙 위반을 관측한 지점에서 찍는 값이라(prompts/lead/okstra-lead-contract.md)
|
|
22
|
+
# 워커 자기신고와 성격이 다르고, 그 자체로 okstra 배선 문제를 가리킨다. 확산이 없어도
|
|
23
|
+
# — 한 프로젝트·한 태스크 안에서만 반복돼도 — 최소 근거 수를 우회한다.
|
|
24
|
+
SELF_SUFFICIENT_SIGNALS = ("contractViolation",)
|
|
25
|
+
|
|
26
|
+
SIGNAL_SUPPORT = {
|
|
27
|
+
"contractViolation": OKSTRA_DEFECT,
|
|
28
|
+
"okstraOwnedPath": OKSTRA_DEFECT,
|
|
29
|
+
"invariantViolated": OKSTRA_DEFECT,
|
|
30
|
+
"taskSpread": OKSTRA_DEFECT,
|
|
31
|
+
"projectSpread": OKSTRA_DEFECT,
|
|
32
|
+
"agentSpread": OKSTRA_DEFECT,
|
|
33
|
+
"phaseLock": OKSTRA_DEFECT,
|
|
34
|
+
"declaredCause": ENVIRONMENT_POLICY,
|
|
35
|
+
"targetSourcePath": TARGET_CODE,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# okstra 가 소유한 표면. 여기서 난 실패는 타겟 코드가 아니라 okstra 배선 문제다.
|
|
39
|
+
# 태스크 워크트리(~/.okstra/worktrees/...)는 제외한다 — 그 디렉터리는 타겟 저장소의
|
|
40
|
+
# 체크아웃이라 거기서 난 실패는 남의 코드에서 난 것이다.
|
|
41
|
+
_OKSTRA_PATH_MARKERS = (
|
|
42
|
+
"/.okstra/tasks/", "/.okstra/runs/", "/.okstra/decisions/",
|
|
43
|
+
"/.okstra/project.json", "/.okstra/glossary.md",
|
|
44
|
+
"/.okstra/recent.jsonl", "/.okstra/active.jsonl", "/.okstra/bin/",
|
|
45
|
+
"okstra_ctl", "scripts/okstra-",
|
|
46
|
+
)
|
|
47
|
+
# 환경 정책 사안으로 미리 분류된 cause. sandbox-denied 는 실측상 오진이 많아
|
|
48
|
+
# 여기 넣지 않는다 — 프로브 증거 검사는 error_issue.validate_candidate 가 맡는다.
|
|
49
|
+
_ENVIRONMENT_CAUSES = ("auth-failed", "service-unavailable")
|
|
50
|
+
|
|
51
|
+
_FREETEXT_FIELDS = ("message", "stderrExcerpt", "command")
|
|
52
|
+
|
|
53
|
+
# 경로 신호가 어느 필드를 읽었는지 알리는 라벨. 이 문자열은 공개 이슈 본문의
|
|
54
|
+
# source 열에 그대로 실리므로 error_issue 의 유출 허용목록이 같은 값을 참조한다 —
|
|
55
|
+
# 여기서 필드가 늘면 라벨과 허용목록이 함께 따라간다.
|
|
56
|
+
FREETEXT_SOURCE = "/".join(_FREETEXT_FIELDS)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _freetext(rec: dict) -> str:
|
|
60
|
+
"""자유 텍스트 필드를 잇되 프로젝트 루트 접두는 지우고 본다.
|
|
61
|
+
|
|
62
|
+
`message`·`stderrExcerpt`·`command` 에는 타겟의 절대 프로젝트 루트가 통째로
|
|
63
|
+
박혀 있다. 루트 경로 자체에 `app/`·`lib/` 같은 조각이 들어 있으면 실패가
|
|
64
|
+
어디서 났든 `targetSourcePath` 가 켜진다 — 실측에서 `okstraOwnedPath` 가
|
|
65
|
+
켜진 103개 클러스터 중 86개가 `targetSourcePath` 도 함께 켰고, 그중 83개는
|
|
66
|
+
사용자의 프로젝트가 `.../FontsNinja/app/` 아래 있다는 이유뿐이었다.
|
|
67
|
+
|
|
68
|
+
루트를 지운 텍스트로 계산하면 31개 클러스터의 판정·반대신호가 달라진다.
|
|
69
|
+
반대 신호는 후보를 `skip` 으로 떨어뜨리므로 이 오발화는 본문 잡음이 아니라
|
|
70
|
+
진짜 결함을 조용히 묻는다 — 실측 7건의 반대신호 후보 중 4건이 깨끗해지고
|
|
71
|
+
그중 하나는 `contract-violation` 이다."""
|
|
72
|
+
root = str(rec.get("_projectRoot", "") or "")
|
|
73
|
+
parts = []
|
|
74
|
+
for field in _FREETEXT_FIELDS:
|
|
75
|
+
value = str(rec.get(field, "") or "")
|
|
76
|
+
parts.append(value.replace(root, "") if root else value)
|
|
77
|
+
return " ".join(parts)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _has_okstra_path(rec: dict) -> bool:
|
|
81
|
+
text = _freetext(rec)
|
|
82
|
+
return any(marker in text for marker in _OKSTRA_PATH_MARKERS)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _has_target_path(rec: dict) -> bool:
|
|
86
|
+
"""okstra 경로가 함께 잡혀도 단락하지 않는다 — 둘 다 켜두면 모순이
|
|
87
|
+
conflicting 으로 드러나 사람이 판단할 수 있다. 단락시키면 반대 근거가
|
|
88
|
+
아예 사라진다."""
|
|
89
|
+
text = _freetext(rec)
|
|
90
|
+
return any(seg in text for seg in ("src/", "lib/", "app/", "tests/"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _distinct(records: list[dict], field: str) -> int:
|
|
94
|
+
return len({str(r.get(field, "")) for r in records if r.get(field)})
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _signal(name: str, value, source: str) -> dict:
|
|
98
|
+
return {"signal": name, "value": value, "source": source}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def compute_signals(records: list[dict]) -> list[dict]:
|
|
102
|
+
"""한 클러스터의 원본 레코드에서 신호를 계산한다."""
|
|
103
|
+
if not records:
|
|
104
|
+
return []
|
|
105
|
+
phases = {str(r.get("phase", "")) for r in records if r.get("phase")}
|
|
106
|
+
causes = [
|
|
107
|
+
str(r.get("context", {}).get("cause", ""))
|
|
108
|
+
for r in records
|
|
109
|
+
if isinstance(r.get("context"), dict) and r.get("context", {}).get("cause")
|
|
110
|
+
]
|
|
111
|
+
# cause 는 워커가 스스로 적는 값이라, 1건짜리 자기신고가 클러스터 전체를
|
|
112
|
+
# 환경 사안으로 돌리지 못하게 과반을 요구한다. sandbox-denied 를 아예 뺀 것과
|
|
113
|
+
# 같은 불신을 나머지 cause 에도 건다.
|
|
114
|
+
declared = [c for c in causes if c in _ENVIRONMENT_CAUSES]
|
|
115
|
+
environment_cause = ""
|
|
116
|
+
if declared:
|
|
117
|
+
top = max(sorted(set(declared)), key=declared.count)
|
|
118
|
+
if declared.count(top) * 2 > len(records):
|
|
119
|
+
environment_cause = top
|
|
120
|
+
return [
|
|
121
|
+
_signal("contractViolation",
|
|
122
|
+
any(r.get("errorType") == "contract-violation" for r in records),
|
|
123
|
+
"errorType"),
|
|
124
|
+
_signal("okstraOwnedPath",
|
|
125
|
+
any(_has_okstra_path(r) for r in records),
|
|
126
|
+
FREETEXT_SOURCE),
|
|
127
|
+
_signal("targetSourcePath",
|
|
128
|
+
any(_has_target_path(r) for r in records),
|
|
129
|
+
FREETEXT_SOURCE),
|
|
130
|
+
_signal("taskSpread", _distinct(records, "taskKey"), "taskKey"),
|
|
131
|
+
_signal("projectSpread", _distinct(records, "_projectRoot"), "_projectRoot"),
|
|
132
|
+
_signal("agentSpread", _distinct(records, "agent"), "agent"),
|
|
133
|
+
_signal("phaseLock", len(phases) == 1, "phase"),
|
|
134
|
+
_signal("declaredCause", environment_cause, "context.cause"),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def signal_fires(sig: dict) -> bool:
|
|
139
|
+
"""신호가 켜졌는지. 카운트형은 2 이상일 때만 확산 신호로 인정한다.
|
|
140
|
+
|
|
141
|
+
공개 함수다 — 등록 직전 게이트(error_issue.validate_candidate)가 같은 판단을
|
|
142
|
+
다시 걸어야 하는데, 비공개로 두면 그쪽이 자기 판본을 새로 만들면서
|
|
143
|
+
`taskSpread: 1` 같은 꺼진 신호를 근거로 세는 더 약한 규칙이 된다.
|
|
144
|
+
|
|
145
|
+
`value` 가 없는 항목은 꺼진 것으로 본다. 이 함수는 사람이 손으로 고친
|
|
146
|
+
plan.json 을 읽는 자리에 있어, 필드 하나가 빠졌다고 KeyError 로 터지면
|
|
147
|
+
등록 루프가 그 자리에서 끊긴다 — 앞의 후보는 이미 올라간 뒤다."""
|
|
148
|
+
value = sig.get("value")
|
|
149
|
+
if sig["signal"] in ("taskSpread", "projectSpread", "agentSpread"):
|
|
150
|
+
return isinstance(value, int) and value >= 2
|
|
151
|
+
if isinstance(value, bool):
|
|
152
|
+
return value
|
|
153
|
+
return bool(value)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def classify(signals: list[dict]) -> tuple[str, list[dict], list[dict]]:
|
|
157
|
+
"""(분류, 지지 신호, 반대 신호) 를 돌려준다.
|
|
158
|
+
|
|
159
|
+
declaredCause 가 켜지면 환경 정책이 이긴다 — 워커가 명시적으로 분류한
|
|
160
|
+
사안을 신호 개수로 뒤집지 않는다. 그 외에는 최다 지지 분류를 고르고,
|
|
161
|
+
다른 분류를 지지하는 신호는 반대 신호로 그대로 노출한다."""
|
|
162
|
+
fired = [s for s in signals if signal_fires(s)]
|
|
163
|
+
by_class: dict[str, list[dict]] = {}
|
|
164
|
+
for sig in fired:
|
|
165
|
+
by_class.setdefault(SIGNAL_SUPPORT[sig["signal"]], []).append(sig)
|
|
166
|
+
|
|
167
|
+
if by_class.get(ENVIRONMENT_POLICY):
|
|
168
|
+
verdict = ENVIRONMENT_POLICY
|
|
169
|
+
elif not by_class:
|
|
170
|
+
verdict = ""
|
|
171
|
+
else:
|
|
172
|
+
verdict = max(by_class.items(), key=lambda kv: (len(kv[1]), kv[0]))[0]
|
|
173
|
+
|
|
174
|
+
supporting = by_class.get(verdict, [])
|
|
175
|
+
# okstra-defect 만 공개 이슈가 되므로 그 판정에만 최소 근거 수를 요구하고,
|
|
176
|
+
# 약한 신호는 세지 않는다. 다른 분류는 후보에서 빠지는 방향이라 근거 하나로도
|
|
177
|
+
# 해가 없다. supporting 에는 약한 신호도 그대로 담아 이슈 본문에 남긴다.
|
|
178
|
+
if verdict == OKSTRA_DEFECT:
|
|
179
|
+
strong = [s for s in supporting if s["signal"] not in WEAK_SIGNALS]
|
|
180
|
+
alone_is_enough = any(
|
|
181
|
+
s["signal"] in SELF_SUFFICIENT_SIGNALS for s in strong)
|
|
182
|
+
if not alone_is_enough and len(strong) < MIN_SUPPORTING_SIGNALS:
|
|
183
|
+
return "", [], []
|
|
184
|
+
|
|
185
|
+
conflicting = [s for s in fired if SIGNAL_SUPPORT[s["signal"]] != verdict]
|
|
186
|
+
return verdict, supporting, conflicting
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import os
|
|
17
17
|
import re
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
18
19
|
from dataclasses import dataclass, replace
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from typing import Optional
|
|
@@ -41,9 +42,46 @@ __all__ = [
|
|
|
41
42
|
"task_runs_dir",
|
|
42
43
|
"container_paths",
|
|
43
44
|
"okstra_home",
|
|
45
|
+
"find_asset_root",
|
|
44
46
|
]
|
|
45
47
|
|
|
46
48
|
|
|
49
|
+
def find_asset_root(
|
|
50
|
+
relative: Sequence[str],
|
|
51
|
+
*,
|
|
52
|
+
start: Optional[Path] = None,
|
|
53
|
+
is_present: Callable[[Path], bool] = Path.is_file,
|
|
54
|
+
) -> Optional[Path]:
|
|
55
|
+
"""Return the runtime root that carries ``relative``, or None.
|
|
56
|
+
|
|
57
|
+
okstra's assets ship in three layouts that put the same tree at a different
|
|
58
|
+
depth relative to this package: a repo checkout has `scripts/okstra_ctl/`,
|
|
59
|
+
the built runtime has `runtime/python/okstra_ctl/`, and an install has
|
|
60
|
+
`~/.okstra/lib/python/okstra_ctl/` with the asset trees two levels further
|
|
61
|
+
up at `~/.okstra/`. Probing for the asset therefore beats counting parents,
|
|
62
|
+
which is right for exactly one of the three.
|
|
63
|
+
|
|
64
|
+
Pass ``is_present=Path.is_dir`` when ``relative`` names a directory.
|
|
65
|
+
|
|
66
|
+
This reads `OKSTRA_HOME` straight from the environment rather than calling
|
|
67
|
+
`okstra_home()`, and the difference is load-bearing: `okstra_home()` falls
|
|
68
|
+
back to `~/.okstra` when the variable is unset, which would let an
|
|
69
|
+
installed copy shadow the checkout a developer is running from. An unset
|
|
70
|
+
variable — or one whose tree lacks the asset — falls through to the walk.
|
|
71
|
+
"""
|
|
72
|
+
override = os.environ.get("OKSTRA_HOME")
|
|
73
|
+
if override:
|
|
74
|
+
root = Path(override)
|
|
75
|
+
if is_present(root.joinpath(*relative)):
|
|
76
|
+
return root
|
|
77
|
+
|
|
78
|
+
here = Path(start or __file__).resolve()
|
|
79
|
+
for parent in [here, *here.parents]:
|
|
80
|
+
if is_present(parent.joinpath(*relative)):
|
|
81
|
+
return parent
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
47
85
|
_STAGED_TASK_TYPES = ("implementation", "final-verification")
|
|
48
86
|
# 발견용: bash `find -name 'final-report-*.md'` 와 같은 범위 — seq 이전 세대의
|
|
49
87
|
# 타임스탬프 파일명도 잡아야 옛 번들에서 조용히 실패하지 않는다.
|