okstra 0.189.3 → 0.190.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 (178) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.mjs +54 -4
  5. package/dist/commands/chat/chat.mjs.map +1 -1
  6. package/dist/commands/lifecycle/install.mjs +12 -5
  7. package/dist/commands/lifecycle/install.mjs.map +1 -1
  8. package/dist/commands/lifecycle/uninstall.mjs +2 -1
  9. package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
  10. package/dist/lib/host-config.d.mts +5 -3
  11. package/dist/lib/host-config.mjs +89 -31
  12. package/dist/lib/host-config.mjs.map +1 -1
  13. package/docs/architecture/storage-model.md +4 -1
  14. package/docs/architecture.md +3 -3
  15. package/docs/cli.md +11 -9
  16. package/docs/for-ai/skills/okstra-brief-gen.md +1 -1
  17. package/docs/for-ai/skills/okstra-chat.md +3 -3
  18. package/docs/for-ai/skills/okstra-inspect.md +11 -1
  19. package/docs/for-ai/skills/okstra-rollup.md +1 -0
  20. package/docs/for-ai/skills/okstra-run.md +5 -4
  21. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  22. package/docs/project-structure-overview.md +17 -5
  23. package/docs/task-process/README.md +1 -1
  24. package/docs/task-process/error-analysis.md +2 -2
  25. package/docs/task-process/final-verification.md +1 -1
  26. package/docs/task-process/implementation.md +1 -1
  27. package/docs/task-process/release-handoff.md +8 -6
  28. package/docs/task-process/requirements-discovery.md +1 -1
  29. package/package.json +1 -1
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/workers/claude-worker.md +4 -0
  32. package/runtime/bin/okstra-report-translate.py +10 -4
  33. package/runtime/prompts/launch.template.md +11 -5
  34. package/runtime/prompts/lead/adapters/cmux.md +4 -7
  35. package/runtime/prompts/lead/context-loader.md +2 -0
  36. package/runtime/prompts/lead/convergence.md +20 -83
  37. package/runtime/prompts/lead/okstra-lead-contract.md +24 -6
  38. package/runtime/prompts/lead/plan-body-verification.md +10 -3
  39. package/runtime/prompts/lead/report-writer.md +15 -5
  40. package/runtime/prompts/lead/team-contract.md +15 -9
  41. package/runtime/prompts/profiles/_common-contract.md +2 -1
  42. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -0
  43. package/runtime/prompts/profiles/_implementation-diff-review.md +4 -0
  44. package/runtime/prompts/profiles/_implementation-executor.md +9 -5
  45. package/runtime/prompts/profiles/_implementation-self-check.md +2 -0
  46. package/runtime/prompts/profiles/_implementation-verifier.md +3 -4
  47. package/runtime/prompts/profiles/_stage-discipline.md +12 -5
  48. package/runtime/prompts/profiles/final-verification.md +1 -1
  49. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  50. package/runtime/prompts/profiles/implementation.md +2 -0
  51. package/runtime/prompts/profiles/release-handoff.md +4 -3
  52. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  53. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +6 -0
  54. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +1 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +2 -2
  56. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +9 -0
  57. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +1 -0
  58. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  59. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +17 -3
  61. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +1 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
  63. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +1 -0
  64. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -2
  65. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +1 -0
  66. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -2
  67. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -3
  68. package/runtime/python/okstra_ctl/agent/invocation.py +107 -5
  69. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +16 -0
  70. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +3 -2
  71. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +87 -0
  72. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +72 -3
  73. package/runtime/python/okstra_ctl/analysis_packet.py +47 -6
  74. package/runtime/python/okstra_ctl/cmux.py +34 -13
  75. package/runtime/python/okstra_ctl/consumers.py +30 -7
  76. package/runtime/python/okstra_ctl/convergence.py +110 -21
  77. package/runtime/python/okstra_ctl/convergence_provenance.py +83 -0
  78. package/runtime/python/okstra_ctl/dispatch_core.py +127 -5
  79. package/runtime/python/okstra_ctl/dispatch_state.py +17 -9
  80. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  81. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +1 -0
  82. package/runtime/python/okstra_ctl/error_log_write.py +44 -4
  83. package/runtime/python/okstra_ctl/execution_mutation_audit.py +84 -7
  84. package/runtime/python/okstra_ctl/fixed_text.py +25 -7
  85. package/runtime/python/okstra_ctl/group_context.py +475 -10
  86. package/runtime/python/okstra_ctl/handoff.py +109 -32
  87. package/runtime/python/okstra_ctl/handoff_verification.py +80 -0
  88. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +52 -10
  89. package/runtime/python/okstra_ctl/lead_progress.py +291 -0
  90. package/runtime/python/okstra_ctl/model_io/renderers.py +60 -1
  91. package/runtime/python/okstra_ctl/model_io_cli.py +6 -1
  92. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  93. package/runtime/python/okstra_ctl/recap.py +179 -17
  94. package/runtime/python/okstra_ctl/reconcile.py +55 -4
  95. package/runtime/python/okstra_ctl/render.py +34 -8
  96. package/runtime/python/okstra_ctl/report_assembly.py +17 -10
  97. package/runtime/python/okstra_ctl/report_finalize.py +204 -11
  98. package/runtime/python/okstra_ctl/report_html/common.py +241 -113
  99. package/runtime/python/okstra_ctl/report_html/context_links.py +121 -0
  100. package/runtime/python/okstra_ctl/report_html/models.py +11 -5
  101. package/runtime/python/okstra_ctl/report_html/render.py +69 -17
  102. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +8 -1
  103. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +8 -1
  104. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +11 -1
  105. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +13 -1
  106. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +9 -1
  107. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +29 -2
  108. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -9
  109. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +8 -1
  110. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +15 -1
  111. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +7 -1
  112. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +9 -1
  113. package/runtime/python/okstra_ctl/report_translation.py +58 -1
  114. package/runtime/python/okstra_ctl/run.py +54 -14
  115. package/runtime/python/okstra_ctl/stage_targets.py +66 -0
  116. package/runtime/python/okstra_ctl/verdict_blocks.py +39 -12
  117. package/runtime/python/okstra_ctl/wizard/__init__.py +148 -0
  118. package/runtime/python/okstra_ctl/wizard/__main__.py +13 -0
  119. package/runtime/python/okstra_ctl/wizard/cli.py +176 -0
  120. package/runtime/python/okstra_ctl/wizard/confirmation.py +331 -0
  121. package/runtime/python/okstra_ctl/wizard/engine.py +363 -0
  122. package/runtime/python/okstra_ctl/wizard/ids.py +411 -0
  123. package/runtime/python/okstra_ctl/wizard/prompts.py +202 -0
  124. package/runtime/python/okstra_ctl/wizard/registry.py +836 -0
  125. package/runtime/python/okstra_ctl/wizard/render.py +189 -0
  126. package/runtime/python/okstra_ctl/wizard/roles.py +737 -0
  127. package/runtime/python/okstra_ctl/wizard/sources.py +703 -0
  128. package/runtime/python/okstra_ctl/wizard/state.py +689 -0
  129. package/runtime/python/okstra_ctl/wizard/statefile.py +145 -0
  130. package/runtime/python/okstra_ctl/wizard/steps_analysis.py +558 -0
  131. package/runtime/python/okstra_ctl/wizard/steps_identity.py +828 -0
  132. package/runtime/python/okstra_ctl/wizard/steps_options.py +340 -0
  133. package/runtime/python/okstra_ctl/wizard/steps_plan.py +959 -0
  134. package/runtime/python/okstra_ctl/wizard/steps_roles.py +672 -0
  135. package/runtime/python/okstra_ctl/worker_prompt_contract.py +43 -3
  136. package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
  137. package/runtime/python/okstra_ctl/workflow.py +2 -2
  138. package/runtime/python/okstra_ctl/worktree/__init__.py +2 -0
  139. package/runtime/python/okstra_ctl/worktree/cleanliness.py +7 -4
  140. package/runtime/python/okstra_ctl/worktree/git_ops.py +7 -0
  141. package/runtime/python/okstra_ctl/worktree/naming.py +15 -6
  142. package/runtime/python/okstra_ctl/worktree/provision.py +2 -1
  143. package/runtime/python/okstra_ctl/worktree_registry.py +27 -0
  144. package/runtime/python/okstra_ctl/wrapper_status.py +4 -0
  145. package/runtime/schemas/final-report-v2.0.schema.json +4 -0
  146. package/runtime/schemas/final-report-v3.0.schema.json +4 -0
  147. package/runtime/skills/okstra-brief-gen/SKILL.md +22 -3
  148. package/runtime/skills/okstra-chat/SKILL.md +8 -6
  149. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  150. package/runtime/skills/okstra-inspect/facets/recap.md +52 -4
  151. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  152. package/runtime/skills/okstra-run/SKILL.md +17 -7
  153. package/runtime/skills/okstra-schedule-gen/SKILL.md +2 -0
  154. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  155. package/runtime/templates/reports/brief.template.md +15 -7
  156. package/runtime/templates/reports/final-verification-input.template.md +2 -0
  157. package/runtime/templates/reports/group-context.template.md +7 -0
  158. package/runtime/templates/reports/html/base.template.html +13 -3
  159. package/runtime/templates/reports/html/i18n/en.json +43 -5
  160. package/runtime/templates/reports/html/i18n/ko.json +43 -5
  161. package/runtime/templates/reports/html/macros/forms.html +4 -4
  162. package/runtime/templates/reports/html/tasks/final-verification.template.html +11 -1
  163. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +16 -15
  164. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +5 -5
  165. package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
  166. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +1 -1
  167. package/runtime/templates/reports/implementation-planning-input.template.md +3 -0
  168. package/runtime/templates/reports/improvement-discovery-input.template.md +3 -0
  169. package/runtime/templates/reports/release-handoff-input.template.md +5 -3
  170. package/runtime/templates/reports/schedule.template.md +13 -4
  171. package/runtime/templates/reports/task-brief.template.md +1 -1
  172. package/runtime/templates/reverify-output-contract.md +5 -0
  173. package/runtime/templates/worker-error-contract.md +2 -0
  174. package/runtime/templates/worker-prompt-preamble.md +1 -1
  175. package/runtime/validators/validate-report-views.py +30 -1
  176. package/runtime/validators/validate-run.py +194 -25
  177. package/runtime/validators/validate_session_conformance.py +102 -2
  178. package/runtime/python/okstra_ctl/wizard.py +0 -7168
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import datetime as dt
13
13
  import json
14
+ import re
14
15
  import os
15
16
  from pathlib import Path
16
17
 
@@ -186,6 +187,37 @@ def append_jsonl_line(path, record):
186
187
  os.close(fd)
187
188
 
188
189
 
190
+ _LEAD_PHASE_LABEL = re.compile(r"^(?:phase-)?\d+(?:\.\d+)?$")
191
+
192
+
193
+ def task_type_from_errors_log_path(out_path) -> str | None:
194
+ """`…/runs/<task-type>[/stage-N]/logs/errors-*.jsonl` 에서 task type 을 읽는다."""
195
+ parts = Path(str(out_path)).parts
196
+ if "runs" not in parts:
197
+ return None
198
+ index = parts.index("runs")
199
+ return parts[index + 1] if index + 1 < len(parts) else None
200
+
201
+
202
+ def normalize_phase_label(phase, out_path) -> tuple[str | None, str | None]:
203
+ """``(phase, leadPhase)`` — 원장의 `phase` 는 task type 어휘 하나로 통일한다.
204
+
205
+ 리드 계약은 자기 내부 단계 번호(`--phase 5.5` = 수렴 라운드)를 쓰고 워커는
206
+ task type 이름을 쓰므로, 한 run 의 errors.jsonl 에 `final-verification`·`5`·
207
+ `5.5` 가 섞였고 `prior-run-errors.md` 의 표가 그대로 그 셋을 나열했다(실측
208
+ 2026-09-06, fontsninja-v3-site dev-10626). 숫자 라벨은 `leadPhase` 로 보존하고
209
+ `phase` 는 로그 경로가 말하는 task type 으로 적는다. 경로에서 읽을 수 없으면
210
+ 라벨을 그대로 둔다 — 지어내지 않는다.
211
+ """
212
+ if phase is None:
213
+ return None, None
214
+ raw = str(phase)
215
+ if not _LEAD_PHASE_LABEL.match(raw):
216
+ return raw, None
217
+ task_type = task_type_from_errors_log_path(out_path)
218
+ return (task_type or raw), raw
219
+
220
+
189
221
  def append_observed(
190
222
  *,
191
223
  out_path,
@@ -216,11 +248,12 @@ def append_observed(
216
248
  # log: a written-then-flagged record is still a record someone can cite.
217
249
  context = normalize_cause_context(context, message=message)
218
250
  ts = _iso(now or _now_utc())
251
+ phase_value, lead_phase = normalize_phase_label(phase, out_path)
219
252
  rec = {
220
253
  "ts": ts,
221
254
  "recordedAt": ts,
222
255
  "taskKey": task_key,
223
- "phase": str(phase),
256
+ "phase": phase_value,
224
257
  "agent": agent,
225
258
  "agentRole": agent_role,
226
259
  "model": model,
@@ -234,6 +267,8 @@ def append_observed(
234
267
  "stderrExcerpt": truncate_stderr(stderr_excerpt),
235
268
  "context": context,
236
269
  }
270
+ if lead_phase is not None:
271
+ rec["leadPhase"] = lead_phase
237
272
  from okstra_ctl.execution_identity import stored_identity
238
273
 
239
274
  rec.update(stored_identity(identity))
@@ -241,14 +276,17 @@ def append_observed(
241
276
  return rec
242
277
 
243
278
 
244
- def _worker_sidecar_record(entry, *, recorded_at, task_key, agent, agent_role, model, identity):
279
+ def _worker_sidecar_record(
280
+ entry, *, recorded_at, task_key, agent, agent_role, model, identity, out_path=None,
281
+ ):
245
282
  error_type = entry.get("errorType")
246
283
  if error_type not in ALLOWED_ERROR_TYPES:
247
284
  raise ValueError(f"invalid errorType in sidecar: {error_type!r}")
285
+ phase_value, lead_phase = normalize_phase_label(entry.get("phase"), out_path)
248
286
  rec = {
249
287
  "ts": entry.get("ts"), "recordedAt": recorded_at,
250
288
  "taskKey": task_key,
251
- "phase": str(entry.get("phase")) if entry.get("phase") is not None else None,
289
+ "phase": phase_value,
252
290
  "agent": agent, "agentRole": agent_role, "model": model,
253
291
  "source": "worker-reported", "errorType": error_type,
254
292
  "command": entry.get("command"), "commandKind": entry.get("commandKind"),
@@ -259,6 +297,8 @@ def _worker_sidecar_record(entry, *, recorded_at, task_key, agent, agent_role, m
259
297
  entry.get("context"), message=entry.get("message")
260
298
  ),
261
299
  }
300
+ if lead_phase is not None:
301
+ rec["leadPhase"] = lead_phase
262
302
  from okstra_ctl.execution_identity import stored_identity
263
303
 
264
304
  rec.update(stored_identity(identity))
@@ -315,7 +355,7 @@ def dump_from_worker_sidecar(
315
355
  for entry in payload.get("errors") or []:
316
356
  rec = _worker_sidecar_record(
317
357
  entry, recorded_at=recorded_at, task_key=task_key, agent=agent,
318
- agent_role=agent_role, model=model, identity=identity,
358
+ agent_role=agent_role, model=model, identity=identity, out_path=out_path,
319
359
  )
320
360
  append_jsonl_line(out_path, rec)
321
361
  count += 1
@@ -10,6 +10,7 @@ from dataclasses import dataclass
10
10
  from pathlib import Path
11
11
  from typing import Any
12
12
 
13
+ from .worktree.cleanliness import nested_worktree_excludes
13
14
  from .write_policy import WritePolicy
14
15
 
15
16
 
@@ -95,17 +96,27 @@ class MutationAuditResult:
95
96
  before_digest: str
96
97
  after_digest: str
97
98
  git_projection: Mapping[str, Any]
99
+ # artifact root(프로젝트 루트)에 새로 생긴 비추적 파일 중 `.okstra/` 밖의 것.
100
+ # 워커의 도구가 남긴 로그·캐시라 위반이 아니라 기록이다 — 실측(2026-09-06,
101
+ # `fontsninja-v3-site` final-verification 001): 검증자의 브라우저 도구가
102
+ # `.playwright-mcp/*.log` 2건을 프로젝트 루트에 남겼고, 그 둘로 26KB 결과가
103
+ # `contract-failed-unattributed` 로 폐기됐다. 도구 사용 자체의 규칙 위반은
104
+ # 에러 원장(contract-violation)이 따로 잡는다.
105
+ untracked_artifact_paths: tuple[str, ...] = ()
106
+ warnings: tuple[str, ...] = ()
98
107
 
99
108
  def change_summary(self) -> dict[str, Any]:
100
109
  return {
101
110
  "mutationStatus": self.status,
102
111
  "changedPaths": list(self.changed_paths),
103
112
  "untrackedPaths": list(self.untracked_paths),
113
+ "untrackedArtifactPaths": list(self.untracked_artifact_paths),
104
114
  "sourceChanged": self.source_changed,
105
115
  "gitChanged": self.git_changed,
106
116
  "attribution": self.attribution,
107
117
  "retryAllowed": self.retry_allowed,
108
118
  "violations": list(self.violations),
119
+ "warnings": list(self.warnings),
109
120
  "beforeDigest": self.before_digest,
110
121
  "afterDigest": self.after_digest,
111
122
  }
@@ -123,12 +134,12 @@ class ExecutionMutationAudit:
123
134
  rows = tuple(policies)
124
135
  root, artifact_root = _validate_batch(rows)
125
136
  generated = _generated_paths(rows)
126
- file_digests = _content_snapshot(root, generated | _ignored_paths(root))
137
+ file_digests = _content_snapshot(root, generated | _non_source_paths(root))
127
138
  artifact_digests = (
128
139
  file_digests
129
140
  if artifact_root == root
130
141
  else _content_snapshot(
131
- artifact_root, generated | _ignored_paths(artifact_root)
142
+ artifact_root, generated | _non_source_paths(artifact_root)
132
143
  )
133
144
  )
134
145
  scratch_digests = _scratch_snapshot(rows)
@@ -195,9 +206,10 @@ class ExecutionMutationAudit:
195
206
  source_changes,
196
207
  out_of_plan_edits,
197
208
  )
198
- violations.extend(
199
- _artifact_policy_failures(before, rows, artifact_changed)
209
+ artifact_failures, untracked_artifact_changes = _artifact_policy_failures(
210
+ before, rows, artifact_changed
200
211
  )
212
+ violations.extend(artifact_failures)
201
213
  status = _terminal_status(
202
214
  rows,
203
215
  source_changed=bool(source_changes),
@@ -221,9 +233,23 @@ class ExecutionMutationAudit:
221
233
  before_digest=before.digest,
222
234
  after_digest=after.digest,
223
235
  git_projection=after.git_projection,
236
+ untracked_artifact_paths=tuple(sorted(untracked_artifact_changes)),
237
+ warnings=_audit_warnings(untracked_artifact_changes),
224
238
  )
225
239
 
226
240
 
241
+ def _audit_warnings(untracked_artifact_changes: set[str]) -> tuple[str, ...]:
242
+ if not untracked_artifact_changes:
243
+ return ()
244
+ shown = sorted(untracked_artifact_changes)[:5]
245
+ more = len(untracked_artifact_changes) - len(shown)
246
+ tail = f" (+{more} more)" if more else ""
247
+ return (
248
+ "worker left untracked files in the artifact root outside `.okstra`: "
249
+ + ", ".join(shown) + tail,
250
+ )
251
+
252
+
227
253
  def _validate_batch(policies: tuple[WritePolicy, ...]) -> tuple[Path, Path]:
228
254
  if not policies:
229
255
  raise MutationAuditError("mutation audit requires at least one policy")
@@ -280,6 +306,11 @@ file failed both acceptance verifiers of an otherwise clean stage.
280
306
 
281
307
  One ecosystem, because one is what has been observed. A second name belongs here
282
308
  when a run produces the same evidence for it, not before.
309
+
310
+ Tool logs that land in the *artifact root* (e.g. `.playwright-mcp/`) are not
311
+ listed here either: `_artifact_policy_failures` records untracked files outside
312
+ `.okstra/` instead of failing on them (observed 2026-09-06, `fontsninja-v3-site`
313
+ final-verification 001).
283
314
  """
284
315
 
285
316
 
@@ -344,6 +375,30 @@ def _excluded(path: Path, excluded: frozenset[str]) -> bool:
344
375
  return any(relative == item or relative.startswith(item + "/") for item in excluded)
345
376
 
346
377
 
378
+ def _non_source_paths(root: Path) -> frozenset[str]:
379
+ """감사에서 뺄 경로 — 레포가 무시하는 경로와 이 트리 안에 등록된 다른 worktree."""
380
+ return _ignored_paths(root) | _nested_worktree_paths(root)
381
+
382
+
383
+ def _nested_worktree_paths(root: Path) -> frozenset[str]:
384
+ """`root` 아래에 git 이 등록한 다른 worktree 의 상대 경로.
385
+
386
+ 형제 배치 이전의 implementation stage worktree 는 task worktree 안
387
+ (`<task>/stage-<N>/`)에 만들어졌고 final-verification 은 판정 뒤까지 그것을
388
+ 남긴다. 중첩 트리는
389
+ 이 워커의 소스가 아니라 다른 브랜치의 체크아웃이므로, 그 안에서 일어난
390
+ 일(빌드 산출물, 다른 run 의 커밋)은 이 워커의 변경이 아니다. 실측
391
+ (2026-09-06, `fontsninja-v3-site` final-verification 001): 두 검증자의
392
+ attempt 가 `stage-1/.next/**` 107·109 경로를 `untrackedPaths` 로 실었다.
393
+ `nested_worktree_excludes` 가 okstra 의 clean 게이트에 쓰는 것과 같은
394
+ 답(`git worktree list`)을 여기서도 쓴다 — 패턴을 따로 들고 있으면 둘이
395
+ 갈린다.
396
+ """
397
+ return frozenset(
398
+ Path(entry).as_posix() for entry in nested_worktree_excludes(root)
399
+ )
400
+
401
+
347
402
  def _ignored_paths(root: Path) -> frozenset[str]:
348
403
  """레포가 스스로 소스가 아니라고 선언한 경로.
349
404
 
@@ -536,11 +591,22 @@ def _source_changes(
536
591
  }
537
592
 
538
593
 
594
+ _OKSTRA_ARTIFACT_SUBTREE = ".okstra"
595
+
596
+
539
597
  def _artifact_policy_failures(
540
598
  snapshot: MutationSnapshot,
541
599
  policies: Sequence[WritePolicy],
542
600
  changed: set[str],
543
- ) -> list[str]:
601
+ ) -> tuple[list[str], set[str]]:
602
+ """``(위반 목록, 위반이 아닌 비추적 신규 경로)``.
603
+
604
+ artifact root 의 변경 중 위반으로 남는 것은 두 부류다 — okstra 산출물
605
+ 서브트리(`.okstra/`) 안의 허용 밖 쓰기와, git 이 추적하는 파일의 변경.
606
+ 그 밖의 비추적 신규 파일은 워커의 도구가 남긴 로그·캐시이므로 소스 root 의
607
+ `_split_tracked` 와 같은 이유로 기록만 한다. artifact root 가 git 레포가
608
+ 아니면 추적 여부를 알 수 없으므로 종전대로 전부 위반으로 본다.
609
+ """
544
610
  allowed = _allowed_artifact_paths(policies)
545
611
  orchestrator = {
546
612
  Path(value).relative_to(snapshot.artifact_root).as_posix()
@@ -553,8 +619,19 @@ def _artifact_policy_failures(
553
619
  and not any(_is_within(path, item) for item in orchestrator)
554
620
  }
555
621
  if snapshot.artifact_root == snapshot.root:
556
- return []
557
- return ["artifact-root change exceeds batch policy union"] if unauthorized else []
622
+ return [], set()
623
+ tracked = _tracked_paths(snapshot.artifact_root)
624
+ if tracked is None:
625
+ untracked_outside: set[str] = set()
626
+ else:
627
+ untracked_outside = {
628
+ path for path in unauthorized
629
+ if path not in tracked
630
+ and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
631
+ }
632
+ violating = unauthorized - untracked_outside
633
+ failures = ["artifact-root change exceeds batch policy union"] if violating else []
634
+ return failures, untracked_outside
558
635
 
559
636
 
560
637
  def _allowed_artifact_paths(policies: Sequence[WritePolicy]) -> set[str]:
@@ -4,16 +4,34 @@ from __future__ import annotations
4
4
  import re
5
5
 
6
6
 
7
+ _CONTROL = re.compile(r"[\x00-\x09\x0b\x0c\x0e-\x1f\x7f-\x9f\u2028\u2029]")
8
+
9
+
10
+ def _one_line(text: str) -> str:
11
+ """줄바꿈은 공백으로, 나머지 제어 문자는 `\\uXXXX` 로 바꿔 한 줄로 만든다."""
12
+ text = text.replace("\n", " ").replace("\r", " ")
13
+ return _CONTROL.sub(lambda match: f"\\u{ord(match.group(0)):04X}", text)
14
+
15
+
7
16
  def scalar(value: object) -> str:
8
- """복합 값과 Markdown 줄 삽입을 노출하지 않는 scalar 문자열이다."""
17
+ """복합 값과 Markdown 줄 삽입을 노출하지 않는 scalar 문자열이다.
18
+
19
+ `line` 이 이 값을 인라인 코드 span(백틱 한 쌍) 안에 넣으므로 값 안의 백틱은
20
+ 이스케이프한다. 백틱이 표기 그대로 남아야 하는 본문에는 `block` 을 쓴다.
21
+ """
9
22
  if value is None or value == "" or not isinstance(value, (str, int, float, bool)):
10
23
  return "-"
11
- text = str(value).replace("`", "\\`").replace("\n", " ").replace("\r", " ")
12
- return re.sub(
13
- r"[\x00-\x09\x0b\x0c\x0e-\x1f\x7f-\x9f\u2028\u2029]",
14
- lambda match: f"\\u{ord(match.group(0)):04X}",
15
- text,
16
- )
24
+ return _one_line(str(value).replace("`", "\\`"))
25
+
26
+
27
+ def block(text: str) -> str:
28
+ """코드 span 밖에 놓이는 본문 한 덩어리의 투영이다.
29
+
30
+ 백틱은 그대로 둔다 — 본문의 백틱은 코드 표기이고, 이스케이프하면 번역자가 그
31
+ 백슬래시를 그대로 옮겨 렌더링에 남는다. 줄바꿈과 제어 문자 처리는 `scalar`
32
+ 와 같아서 본문이 다음 헤딩을 위조할 수 없다.
33
+ """
34
+ return _one_line(text)
17
35
 
18
36
 
19
37
  def line(label: str, value: object) -> str: