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.
Files changed (84) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture/storage-model.md +2 -0
  3. package/docs/architecture.md +1 -1
  4. package/docs/cli.md +8 -3
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-inspect.md +3 -0
  7. package/docs/for-ai/skills/okstra-run.md +2 -1
  8. package/docs/for-ai/skills/okstra-user-response.md +5 -5
  9. package/docs/project-structure-overview.md +5 -1
  10. package/docs/task-process/implementation.md +28 -0
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/workers/report-writer-worker.md +1 -1
  14. package/runtime/bin/okstra-claude-exec.sh +4 -1
  15. package/runtime/prompts/host-orchestration/README.md +18 -0
  16. package/runtime/prompts/host-orchestration/implementation.md +57 -0
  17. package/runtime/prompts/launch.template.md +10 -1
  18. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  19. package/runtime/prompts/lead/context-loader.md +5 -2
  20. package/runtime/prompts/lead/convergence.md +3 -1
  21. package/runtime/prompts/lead/plan-body-verification.md +21 -2
  22. package/runtime/prompts/lead/report-writer.md +1 -1
  23. package/runtime/prompts/lead/team-contract.md +2 -1
  24. package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
  25. package/runtime/prompts/profiles/_common-contract.md +3 -1
  26. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  27. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  28. package/runtime/prompts/wizard/prompts.ko.json +3 -0
  29. package/runtime/python/okstra_ctl/clarification_items.py +9 -0
  30. package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
  31. package/runtime/python/okstra_ctl/convergence.py +168 -11
  32. package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
  33. package/runtime/python/okstra_ctl/error_issue.py +640 -0
  34. package/runtime/python/okstra_ctl/error_report.py +56 -0
  35. package/runtime/python/okstra_ctl/error_zip.py +23 -10
  36. package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
  37. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
  38. package/runtime/python/okstra_ctl/issue_signals.py +186 -0
  39. package/runtime/python/okstra_ctl/paths.py +38 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
  41. package/runtime/python/okstra_ctl/profile_show.py +134 -0
  42. package/runtime/python/okstra_ctl/recap.py +63 -0
  43. package/runtime/python/okstra_ctl/render_final_report.py +11 -62
  44. package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
  45. package/runtime/python/okstra_ctl/report_html/render.py +9 -8
  46. package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
  47. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
  48. package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
  49. package/runtime/python/okstra_ctl/report_translation.py +4 -0
  50. package/runtime/python/okstra_ctl/report_views.py +7 -3
  51. package/runtime/python/okstra_ctl/run.py +41 -2
  52. package/runtime/python/okstra_ctl/run_audit.py +477 -0
  53. package/runtime/python/okstra_ctl/usage_cells.py +47 -0
  54. package/runtime/python/okstra_ctl/user_response.py +25 -10
  55. package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
  56. package/runtime/python/okstra_ctl/wizard.py +64 -10
  57. package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
  58. package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
  59. package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
  60. package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
  61. package/runtime/schemas/final-report-v1.0.schema.json +14 -0
  62. package/runtime/schemas/final-report-v2.0.schema.json +56 -2
  63. package/runtime/skills/okstra-inspect/SKILL.md +3 -1
  64. package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
  65. package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
  66. package/runtime/skills/okstra-run/SKILL.md +28 -10
  67. package/runtime/skills/okstra-user-response/SKILL.md +18 -18
  68. package/runtime/templates/reports/final-report.template.md +4 -0
  69. package/runtime/templates/reports/html/assets/base.css +14 -1
  70. package/runtime/templates/reports/html/base.template.html +42 -0
  71. package/runtime/templates/reports/html/i18n/en.json +30 -1
  72. package/runtime/templates/reports/html/i18n/ko.json +30 -1
  73. package/runtime/templates/reports/html/macros/forms.html +15 -0
  74. package/runtime/templates/reports/html/macros/visualizations.html +3 -2
  75. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
  76. package/runtime/templates/reports/i18n/en.json +2 -0
  77. package/runtime/validators/validate-run.py +331 -208
  78. package/runtime/validators/validate_session_conformance.py +102 -32
  79. package/src/cli-registry.mjs +34 -0
  80. package/src/commands/execute/incremental-scope.mjs +10 -0
  81. package/src/commands/execute/worker-audit-check.mjs +35 -0
  82. package/src/commands/inspect/error-issue.mjs +27 -0
  83. package/src/commands/inspect/profile-show.mjs +29 -0
  84. 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 _run_dirs(home: Path) -> list[tuple[str, Path]]:
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 _run_dirs(home):
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
- task_key = str(rec.get("taskKey", ""))
173
- if ":" in task_key:
174
- item["taskKey"] = token + task_key[task_key.index(":"):]
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]) -> tuple[list[dict], list[str]]:
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("sourceProject", "")))
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
- def _stages_blocked_on(coverage: object, clarification_id: str) -> set[int]:
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 not isinstance(row, dict):
102
- continue
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 = _stages_blocked_on(coverage, clarification_id)
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
- ap.add_argument("--cur-base-sha", required=True)
175
- ap.add_argument("--prev-base-sha", required=True)
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
- tuple[Path, ...],
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
- ) -> tuple[Path, ...]:
128
- """Materialize selected initial prompts in request order."""
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
- ) -> tuple[Path, ...]:
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 tuple(prompt.path for prompt in published)
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
  # 타임스탬프 파일명도 잡아야 옛 번들에서 조용히 실패하지 않는다.