okstra 0.201.2 → 0.202.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/bin/okstra +1 -1
- package/dist/commands/lifecycle/setup.mjs +15 -0
- package/dist/commands/lifecycle/setup.mjs.map +1 -1
- package/dist/lib/citation-guidance.d.mts +21 -0
- package/dist/lib/citation-guidance.mjs +79 -0
- package/dist/lib/citation-guidance.mjs.map +1 -0
- package/docs/architecture/storage-model.md +4 -0
- package/docs/architecture.md +1 -1
- package/docs/cli.md +2 -2
- package/docs/for-ai/skills/okstra-manager.md +21 -4
- package/docs/for-ai/skills/okstra-setup.md +9 -0
- package/docs/project-structure-overview.md +11 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +15 -3
- package/runtime/prompts/lead/team-contract.md +2 -0
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/python/okstra_ctl/agent/evidence_recovery.py +78 -0
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
- package/runtime/python/okstra_ctl/convergence_provenance.py +75 -18
- package/runtime/python/okstra_ctl/dispatch_core.py +91 -17
- package/runtime/python/okstra_ctl/execution_identity.py +9 -2
- package/runtime/python/okstra_ctl/execution_manifest.py +6 -2
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +134 -65
- package/runtime/python/okstra_ctl/manager_cli.py +85 -12
- package/runtime/python/okstra_ctl/manager_launch.py +40 -18
- package/runtime/python/okstra_ctl/manager_paths.py +8 -0
- package/runtime/python/okstra_ctl/manager_split.py +474 -0
- package/runtime/python/okstra_ctl/manager_store.py +121 -18
- package/runtime/python/okstra_ctl/manager_sync.py +33 -15
- package/runtime/python/okstra_ctl/manager_view.py +216 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
- package/runtime/python/okstra_ctl/qa_commands.py +15 -0
- package/runtime/python/okstra_ctl/report_finalize.py +13 -6
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
- package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
- package/runtime/python/okstra_ctl/verification_target.py +13 -2
- package/runtime/skills/okstra-manager/SKILL.md +53 -4
- package/runtime/skills/okstra-run/SKILL.md +3 -1
- package/runtime/skills/okstra-setup/SKILL.md +9 -0
- package/runtime/templates/manager/view.template.html +108 -0
- package/runtime/templates/reports/html/i18n/en.json +2 -0
- package/runtime/templates/reports/html/i18n/ko.json +2 -0
- package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
- package/runtime/validators/validate-brief.py +7 -2
- package/runtime/validators/validate-run.py +7 -15
|
@@ -44,6 +44,7 @@ class MutationSnapshot:
|
|
|
44
44
|
# 스냅샷이 아직 실행 중인 디스패치에 남아 있고, 그것을 못 읽으면 그 워커가
|
|
45
45
|
# 통째로 error 가 된다. 두 루트가 같으면 None.
|
|
46
46
|
artifact_git_head: str | None = None
|
|
47
|
+
artifact_observation_roots: tuple[str, ...] = ()
|
|
47
48
|
|
|
48
49
|
def to_payload(self) -> dict[str, Any]:
|
|
49
50
|
return {
|
|
@@ -57,6 +58,7 @@ class MutationSnapshot:
|
|
|
57
58
|
"digest": self.digest,
|
|
58
59
|
"orchestratorPaths": list(self.orchestrator_paths),
|
|
59
60
|
"artifactGitHead": self.artifact_git_head,
|
|
61
|
+
"artifactObservationRoots": list(self.artifact_observation_roots),
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
@classmethod
|
|
@@ -75,6 +77,7 @@ class MutationSnapshot:
|
|
|
75
77
|
str(payload["artifactGitHead"])
|
|
76
78
|
if payload.get("artifactGitHead") else None
|
|
77
79
|
),
|
|
80
|
+
artifact_observation_roots=tuple(payload.get("artifactObservationRoots", ())),
|
|
78
81
|
)
|
|
79
82
|
expected = _snapshot_digest(
|
|
80
83
|
snapshot.root,
|
|
@@ -85,6 +88,7 @@ class MutationSnapshot:
|
|
|
85
88
|
snapshot.scratch_digests,
|
|
86
89
|
snapshot.git_projection,
|
|
87
90
|
snapshot.orchestrator_paths,
|
|
91
|
+
snapshot.artifact_observation_roots,
|
|
88
92
|
)
|
|
89
93
|
if snapshot.digest != expected:
|
|
90
94
|
raise MutationAuditError("mutation snapshot digest does not match payload")
|
|
@@ -115,6 +119,10 @@ class MutationAuditResult:
|
|
|
115
119
|
# 에러 원장(contract-violation)이 따로 잡는다.
|
|
116
120
|
untracked_artifact_paths: tuple[str, ...] = ()
|
|
117
121
|
warnings: tuple[str, ...] = ()
|
|
122
|
+
changed_artifact_paths: tuple[str, ...] = ()
|
|
123
|
+
declared_out_of_plan_paths: tuple[str, ...] = ()
|
|
124
|
+
unobserved_artifact_paths: tuple[str, ...] = ()
|
|
125
|
+
external_workspace_changes: tuple[str, ...] = ()
|
|
118
126
|
|
|
119
127
|
def change_summary(self) -> dict[str, Any]:
|
|
120
128
|
return {
|
|
@@ -128,6 +136,10 @@ class MutationAuditResult:
|
|
|
128
136
|
"retryAllowed": self.retry_allowed,
|
|
129
137
|
"violations": list(self.violations),
|
|
130
138
|
"warnings": list(self.warnings),
|
|
139
|
+
"changedArtifactPaths": list(self.changed_artifact_paths),
|
|
140
|
+
"declaredOutOfPlanPaths": list(self.declared_out_of_plan_paths),
|
|
141
|
+
"unobservedArtifactPaths": list(self.unobserved_artifact_paths),
|
|
142
|
+
"externalWorkspaceChanges": list(self.external_workspace_changes),
|
|
131
143
|
"beforeDigest": self.before_digest,
|
|
132
144
|
"afterDigest": self.after_digest,
|
|
133
145
|
}
|
|
@@ -147,12 +159,14 @@ class ExecutionMutationAudit:
|
|
|
147
159
|
generated = _generated_paths(rows)
|
|
148
160
|
file_digests = source_content_snapshot(root, generated)
|
|
149
161
|
artifact_digests = (
|
|
150
|
-
file_digests
|
|
162
|
+
dict(file_digests)
|
|
151
163
|
if artifact_root == root
|
|
152
164
|
else _content_snapshot(
|
|
153
165
|
artifact_root, generated | _non_source_paths(artifact_root)
|
|
154
166
|
)
|
|
155
167
|
)
|
|
168
|
+
observation_roots = _artifact_observation_roots(rows)
|
|
169
|
+
artifact_digests.update(_observed_artifact_contents(artifact_root, observation_roots))
|
|
156
170
|
scratch_digests = _scratch_snapshot(rows)
|
|
157
171
|
git_projection = _git_projection(root)
|
|
158
172
|
orchestrator = tuple(
|
|
@@ -168,6 +182,7 @@ class ExecutionMutationAudit:
|
|
|
168
182
|
scratch_digests,
|
|
169
183
|
git_projection,
|
|
170
184
|
orchestrator,
|
|
185
|
+
observation_roots,
|
|
171
186
|
)
|
|
172
187
|
return MutationSnapshot(
|
|
173
188
|
root=root,
|
|
@@ -182,6 +197,7 @@ class ExecutionMutationAudit:
|
|
|
182
197
|
artifact_git_head=(
|
|
183
198
|
None if artifact_root == root else _git_head(artifact_root)
|
|
184
199
|
),
|
|
200
|
+
artifact_observation_roots=observation_roots,
|
|
185
201
|
)
|
|
186
202
|
|
|
187
203
|
def compare(
|
|
@@ -203,6 +219,13 @@ class ExecutionMutationAudit:
|
|
|
203
219
|
artifact_changed = _changed_keys(
|
|
204
220
|
before.artifact_digests, after.artifact_digests
|
|
205
221
|
)
|
|
222
|
+
unobserved = {
|
|
223
|
+
path for path in artifact_changed
|
|
224
|
+
if path not in before.artifact_digests
|
|
225
|
+
and any(_is_within(path, root) for root in after.artifact_observation_roots)
|
|
226
|
+
and not any(_is_within(path, root) for root in before.artifact_observation_roots)
|
|
227
|
+
}
|
|
228
|
+
artifact_changed -= unobserved
|
|
206
229
|
scratch_changed = _changed_keys(
|
|
207
230
|
before.scratch_digests, after.scratch_digests
|
|
208
231
|
)
|
|
@@ -218,22 +241,27 @@ class ExecutionMutationAudit:
|
|
|
218
241
|
after,
|
|
219
242
|
rows,
|
|
220
243
|
source_changes,
|
|
221
|
-
out_of_plan_edits,
|
|
222
244
|
)
|
|
223
|
-
artifact_failures, untracked_artifact_changes, switched = (
|
|
245
|
+
artifact_failures, untracked_artifact_changes, switched, external = (
|
|
224
246
|
_artifact_policy_failures(before, rows, artifact_changed, after=after)
|
|
225
247
|
)
|
|
226
248
|
violations.extend(artifact_failures)
|
|
249
|
+
worker_artifact_changes = {
|
|
250
|
+
path for path in artifact_changed
|
|
251
|
+
if path not in untracked_artifact_changes and path not in switched and path not in external
|
|
252
|
+
and not any(_is_relative_to(before.artifact_root / path, Path(item))
|
|
253
|
+
for item in before.orchestrator_paths)
|
|
254
|
+
}
|
|
227
255
|
status = _terminal_status(
|
|
228
256
|
rows,
|
|
229
|
-
source_changed=bool(source_changes),
|
|
257
|
+
source_changed=bool(source_changes or worker_artifact_changes),
|
|
230
258
|
git_changed=git_changed,
|
|
231
259
|
attempt_succeeded=attempt_succeeded,
|
|
232
260
|
result_present=result_present,
|
|
233
261
|
violations=violations,
|
|
234
262
|
)
|
|
235
263
|
retry_allowed = _retry_allowed(
|
|
236
|
-
status, bool(source_changes), git_changed, bool(scratch_changed)
|
|
264
|
+
status, bool(source_changes or worker_artifact_changes), git_changed, bool(scratch_changed)
|
|
237
265
|
)
|
|
238
266
|
return MutationAuditResult(
|
|
239
267
|
status=status,
|
|
@@ -249,10 +277,87 @@ class ExecutionMutationAudit:
|
|
|
249
277
|
git_projection=after.git_projection,
|
|
250
278
|
untracked_artifact_paths=tuple(sorted(untracked_artifact_changes)),
|
|
251
279
|
warnings=_audit_warnings(untracked_artifact_changes)
|
|
252
|
-
+ _branch_switch_warnings(before, after, switched)
|
|
280
|
+
+ _branch_switch_warnings(before, after, switched)
|
|
281
|
+
+ tuple(f"unattributed change outside assigned worktree and artifact paths: {path}"
|
|
282
|
+
for path in sorted(external))
|
|
283
|
+
+ _plan_change_warnings(rows, source_changes | untracked_changes,
|
|
284
|
+
artifact_changed, out_of_plan_edits, unobserved),
|
|
285
|
+
changed_artifact_paths=tuple(sorted(artifact_changed - external)),
|
|
286
|
+
declared_out_of_plan_paths=tuple(out_of_plan_edits),
|
|
287
|
+
unobserved_artifact_paths=tuple(sorted(unobserved)),
|
|
288
|
+
external_workspace_changes=tuple(sorted(external)),
|
|
253
289
|
)
|
|
254
290
|
|
|
255
291
|
|
|
292
|
+
def _observed_artifact_contents(root: Path, observation_roots: Sequence[str]) -> dict[str, str]:
|
|
293
|
+
"""관측 경계의 링크 자체를 기록하고 링크 목적지는 탐색하지 않는다."""
|
|
294
|
+
artifact_digests: dict[str, str] = {}
|
|
295
|
+
for relative in observation_roots:
|
|
296
|
+
target = root / relative
|
|
297
|
+
link = next((parent for parent in (*target.parents, target)
|
|
298
|
+
if parent != root and _is_relative_to(parent, root)
|
|
299
|
+
and parent.is_symlink()), None)
|
|
300
|
+
if link is not None:
|
|
301
|
+
artifact_digests[link.relative_to(root).as_posix()] = _path_digest(link)
|
|
302
|
+
continue
|
|
303
|
+
if target.is_symlink() or target.is_file():
|
|
304
|
+
artifact_digests[relative] = _path_digest(target)
|
|
305
|
+
else:
|
|
306
|
+
artifact_digests.update({
|
|
307
|
+
f"{relative}/{key}": value
|
|
308
|
+
for key, value in _content_snapshot(target, frozenset()).items()
|
|
309
|
+
})
|
|
310
|
+
return artifact_digests
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _artifact_observation_roots(policies: Sequence[WritePolicy]) -> tuple[str, ...]:
|
|
314
|
+
"""현재 배정의 QA와 실행 산출물은 Git 무시 규칙과 무관하게 관측한다."""
|
|
315
|
+
roots: set[str] = set()
|
|
316
|
+
for path in _allowed_artifact_paths(policies):
|
|
317
|
+
parts = Path(path).parts
|
|
318
|
+
if len(parts) < 5 or parts[:2] != (".okstra", "tasks"):
|
|
319
|
+
continue
|
|
320
|
+
if parts[4] == "qa":
|
|
321
|
+
roots.add(Path(*parts[:5]).as_posix())
|
|
322
|
+
elif parts[4] == "runs":
|
|
323
|
+
boundary = next((i for i, part in enumerate(parts)
|
|
324
|
+
if part in {"worker-results", "prompts"}), len(parts) - 1)
|
|
325
|
+
roots.add(Path(*parts[:boundary]).as_posix())
|
|
326
|
+
else:
|
|
327
|
+
roots.add(path)
|
|
328
|
+
return tuple(sorted(roots))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _plan_change_warnings(
|
|
332
|
+
policies: Sequence[WritePolicy], source_changes: set[str],
|
|
333
|
+
artifact_changes: set[str], declared: Sequence[str], unobserved: set[str],
|
|
334
|
+
) -> tuple[str, ...]:
|
|
335
|
+
"""예상 목록과의 차이는 검토 자료이며 쓰기 권한 위반이 아니다."""
|
|
336
|
+
warnings: list[str] = []
|
|
337
|
+
for policy in policies:
|
|
338
|
+
if policy.source_mode != "project-mutation":
|
|
339
|
+
continue
|
|
340
|
+
planned = policy.source_policy.get("plannedPaths", ())
|
|
341
|
+
extra = sorted(path for path in source_changes
|
|
342
|
+
if not any(_is_within(path, item) for item in planned))
|
|
343
|
+
if extra:
|
|
344
|
+
warnings.append("source changes outside expected plan; review scope: " + ", ".join(extra))
|
|
345
|
+
observed = {Path(str(policy.source_policy["allowedRoot"])) / path
|
|
346
|
+
for policy in policies for path in source_changes}
|
|
347
|
+
observed.update(Path(str(policy.artifact_policy["allowedRoot"])) / path
|
|
348
|
+
for policy in policies for path in artifact_changes)
|
|
349
|
+
roots = {Path(str(policy.source_policy["allowedRoot"])) for policy in policies}
|
|
350
|
+
roots.update(Path(str(policy.artifact_policy["allowedRoot"])) for policy in policies)
|
|
351
|
+
missing = sorted(path for path in declared
|
|
352
|
+
if not any(root / path in observed for root in roots))
|
|
353
|
+
if missing:
|
|
354
|
+
warnings.append("declared changes not observed; reconcile evidence: " + ", ".join(missing))
|
|
355
|
+
if unobserved:
|
|
356
|
+
warnings.append(f"artifact baseline coverage unavailable ({len(unobserved)} paths); "
|
|
357
|
+
"independently verify current artifacts; see unobservedArtifactPaths")
|
|
358
|
+
return tuple(warnings)
|
|
359
|
+
|
|
360
|
+
|
|
256
361
|
def _audit_warnings(untracked_artifact_changes: set[str]) -> tuple[str, ...]:
|
|
257
362
|
if not untracked_artifact_changes:
|
|
258
363
|
return ()
|
|
@@ -595,6 +700,7 @@ def _snapshot_digest(
|
|
|
595
700
|
scratch: Mapping[str, str],
|
|
596
701
|
git_projection: Mapping[str, Any],
|
|
597
702
|
orchestrator_paths: Sequence[str],
|
|
703
|
+
observation_roots: Sequence[str] = (),
|
|
598
704
|
) -> str:
|
|
599
705
|
encoded = json.dumps(
|
|
600
706
|
{
|
|
@@ -606,6 +712,7 @@ def _snapshot_digest(
|
|
|
606
712
|
"scratch": scratch,
|
|
607
713
|
"gitProjection": git_projection,
|
|
608
714
|
"orchestratorPaths": list(orchestrator_paths),
|
|
715
|
+
**({"artifactObservationRoots": list(observation_roots)} if observation_roots else {}),
|
|
609
716
|
},
|
|
610
717
|
sort_keys=True,
|
|
611
718
|
separators=(",", ":"),
|
|
@@ -651,22 +758,12 @@ def _artifact_policy_failures(
|
|
|
651
758
|
changed: set[str],
|
|
652
759
|
*,
|
|
653
760
|
after: MutationSnapshot | None = None,
|
|
654
|
-
) -> tuple[list[str], set[str], set[str]]:
|
|
655
|
-
"""
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
`_split_tracked` 와 같은 이유로 기록만 한다. artifact root 가 git 레포가
|
|
661
|
-
아니면 추적 여부를 알 수 없으므로 종전대로 전부 위반으로 본다.
|
|
662
|
-
|
|
663
|
-
추적 파일의 변경 중 **artifact root 의 HEAD 가 실행 창 안에서 옮겨졌고 그
|
|
664
|
-
두 커밋 사이에서 실제로 달라지는 경로**는 워커의 쓰기가 아니라 사람의 브랜치
|
|
665
|
-
전환이다. 실측(2026-09-09, `fontsninja-v3-site` dev-10627 reverify r1b): 워커가
|
|
666
|
-
워크트리에서 읽기만 하는 8분 동안 프로젝트 루트에서 `checkout preprod →
|
|
667
|
-
rebase` 가 있었고, run 브랜치에만 있는 `CardHero.{tsx,styled.ts}` 가 사라져
|
|
668
|
-
완주한 결과가 `contract-failed-unattributed` 로 폐기됐다. 그 경로는 위반에서
|
|
669
|
-
빼고 경고로 남긴다. 전환으로 설명되지 않는 추적 파일 변경은 그대로 위반이다.
|
|
761
|
+
) -> tuple[list[str], set[str], set[str], set[str]]:
|
|
762
|
+
"""위반·비추적 파일·브랜치 전환·외부 작업공간 관측을 분리한다.
|
|
763
|
+
|
|
764
|
+
작업트리와 기본 체크아웃이 다르면 기본 체크아웃 소스의 변경 주체는
|
|
765
|
+
전후 비교로 판단할 수 없다. 배정된 산출물 밖의 변경은 관측으로 남기고,
|
|
766
|
+
`.okstra` 내부의 허용 밖 쓰기와 배정된 소스 정책은 별도로 검사한다.
|
|
670
767
|
"""
|
|
671
768
|
allowed = _allowed_artifact_paths(policies)
|
|
672
769
|
orchestrator = {
|
|
@@ -680,7 +777,7 @@ def _artifact_policy_failures(
|
|
|
680
777
|
and not any(_is_within(path, item) for item in orchestrator)
|
|
681
778
|
}
|
|
682
779
|
if snapshot.artifact_root == snapshot.root:
|
|
683
|
-
|
|
780
|
+
unauthorized = {path for path in unauthorized if _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)}
|
|
684
781
|
tracked = _tracked_paths(snapshot.artifact_root)
|
|
685
782
|
if tracked is None:
|
|
686
783
|
untracked_outside: set[str] = set()
|
|
@@ -697,9 +794,14 @@ def _artifact_policy_failures(
|
|
|
697
794
|
if path in unauthorized and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
|
|
698
795
|
}
|
|
699
796
|
untracked_outside -= switched
|
|
700
|
-
|
|
797
|
+
external = {
|
|
798
|
+
path for path in unauthorized - untracked_outside - switched
|
|
799
|
+
if snapshot.artifact_root != snapshot.root
|
|
800
|
+
and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
|
|
801
|
+
}
|
|
802
|
+
violating = unauthorized - untracked_outside - switched - external
|
|
701
803
|
failures = ["artifact-root change exceeds batch policy union"] if violating else []
|
|
702
|
-
return failures, untracked_outside, switched
|
|
804
|
+
return failures, untracked_outside, switched, external
|
|
703
805
|
|
|
704
806
|
|
|
705
807
|
def _branch_switch_paths(
|
|
@@ -761,7 +863,6 @@ def _policy_violations(
|
|
|
761
863
|
after: MutationSnapshot,
|
|
762
864
|
policies: tuple[WritePolicy, ...],
|
|
763
865
|
source_changes: set[str],
|
|
764
|
-
out_of_plan_edits: Sequence[str],
|
|
765
866
|
) -> list[str]:
|
|
766
867
|
if all(policy.source_mode == "source-readonly" for policy in policies):
|
|
767
868
|
failures = ["readonly source changed"] if source_changes else []
|
|
@@ -769,8 +870,8 @@ def _policy_violations(
|
|
|
769
870
|
failures.append("gitPolicy disabled but Git projection changed")
|
|
770
871
|
return failures
|
|
771
872
|
policy = next(row for row in policies if row.source_mode == "project-mutation")
|
|
772
|
-
failures = _source_policy_failures(policy, source_changes
|
|
773
|
-
failures.extend(_git_policy_failures(before, after, policy
|
|
873
|
+
failures = _source_policy_failures(policy, source_changes)
|
|
874
|
+
failures.extend(_git_policy_failures(before, after, policy))
|
|
774
875
|
return failures
|
|
775
876
|
|
|
776
877
|
|
|
@@ -797,37 +898,14 @@ def _stable_git_projection(snapshot: MutationSnapshot) -> dict[str, Any]:
|
|
|
797
898
|
}
|
|
798
899
|
|
|
799
900
|
|
|
800
|
-
def _path_ledger_is_unenforceable(policy: WritePolicy) -> bool:
|
|
801
|
-
"""옛 계획의 미선언 목록만 면제하고, 산출물 전용 계획의 빈 소스 목록은 집행한다.
|
|
802
|
-
|
|
803
|
-
선언 여부가 저장되기 전 정책은 기존의 빈 목록 해석을 유지한다.
|
|
804
|
-
새 정책은 경로 분류 후에도 선언 여부를 보존하므로 산출물만 남은 계획이
|
|
805
|
-
임의의 소스 변경이나 커밋을 허용하지 않는다.
|
|
806
|
-
"""
|
|
807
|
-
source = policy.source_policy
|
|
808
|
-
return (
|
|
809
|
-
source.get("mode") == "project-mutation"
|
|
810
|
-
and not source.get("plannedPaths")
|
|
811
|
-
and not source.get("plannedPathsDeclared", False)
|
|
812
|
-
)
|
|
813
|
-
|
|
814
|
-
|
|
815
901
|
def _source_policy_failures(
|
|
816
902
|
policy: WritePolicy,
|
|
817
903
|
changed: set[str],
|
|
818
|
-
out_of_plan_edits: Sequence[str],
|
|
819
904
|
) -> list[str]:
|
|
820
|
-
planned = set(policy.source_policy.get("plannedPaths", ()))
|
|
821
|
-
declared = set(out_of_plan_edits)
|
|
822
905
|
protected = set(policy.source_policy.get("protectedPaths", ()))
|
|
823
906
|
failures: list[str] = []
|
|
824
|
-
if
|
|
825
|
-
|
|
826
|
-
and not changed <= planned | declared
|
|
827
|
-
):
|
|
828
|
-
failures.append("source changes exceed planned and declared out-of-plan paths")
|
|
829
|
-
if not declared <= changed:
|
|
830
|
-
failures.append("declared out-of-plan path did not change")
|
|
907
|
+
if policy.source_mode == "source-readonly" and changed:
|
|
908
|
+
failures.append("readonly source changed")
|
|
831
909
|
if any(_is_within(path, item) for path in changed for item in protected):
|
|
832
910
|
failures.append("protected path changed")
|
|
833
911
|
return failures
|
|
@@ -837,8 +915,6 @@ def _git_policy_failures(
|
|
|
837
915
|
before: MutationSnapshot,
|
|
838
916
|
after: MutationSnapshot,
|
|
839
917
|
policy: WritePolicy,
|
|
840
|
-
changed: set[str],
|
|
841
|
-
out_of_plan_edits: Sequence[str],
|
|
842
918
|
) -> list[str]:
|
|
843
919
|
git = policy.git_policy
|
|
844
920
|
root = before.root
|
|
@@ -857,23 +933,16 @@ def _git_policy_failures(
|
|
|
857
933
|
failures.append("final Git index contains staged changes")
|
|
858
934
|
if not _is_ancestor(root, str(git.get("expectedBaseCommit")), str(after.git_projection.get("head"))):
|
|
859
935
|
failures.append("final HEAD is not a fast-forward descendant")
|
|
860
|
-
|
|
861
|
-
if _path_ledger_is_unenforceable(policy):
|
|
862
|
-
# The plan predates the declared path column, so there is no ledger
|
|
863
|
-
# anyone can be held to. Every other check above still applies; only
|
|
864
|
-
# the path comparison stands down.
|
|
865
|
-
return failures
|
|
936
|
+
protected = policy.source_policy.get("protectedPaths", ())
|
|
866
937
|
if any(
|
|
867
|
-
|
|
938
|
+
any(_is_within(path, item) for path in paths for item in protected)
|
|
868
939
|
for paths in _commit_paths_by_commit(
|
|
869
940
|
root,
|
|
870
941
|
str(git.get("expectedBaseCommit")),
|
|
871
942
|
str(after.git_projection.get("head")),
|
|
872
943
|
)
|
|
873
944
|
):
|
|
874
|
-
failures.append("commit chain changed paths
|
|
875
|
-
if not changed <= allowed:
|
|
876
|
-
failures.append("working tree changed paths outside source policy")
|
|
945
|
+
failures.append("commit chain changed protected paths")
|
|
877
946
|
return failures
|
|
878
947
|
|
|
879
948
|
|
|
@@ -10,6 +10,7 @@ from okstra_project.dirs import okstra_home
|
|
|
10
10
|
|
|
11
11
|
from .manager_launch import build_launch_packet
|
|
12
12
|
from .fixed_text import line, value_lines
|
|
13
|
+
from .manager_paths import ManagerPathError
|
|
13
14
|
from .manager_store import (
|
|
14
15
|
ManagerError,
|
|
15
16
|
append_directive,
|
|
@@ -19,20 +20,40 @@ from .manager_store import (
|
|
|
19
20
|
discover_projects,
|
|
20
21
|
init_manager,
|
|
21
22
|
link_project,
|
|
23
|
+
list_managers,
|
|
24
|
+
list_projects,
|
|
25
|
+
list_tasks,
|
|
22
26
|
)
|
|
27
|
+
from .manager_split import split_task
|
|
23
28
|
from .manager_sync import status_task, sync_task
|
|
29
|
+
from .manager_view import write_view
|
|
30
|
+
|
|
31
|
+
# list 명령마다 목록 한 줄에 싣는 (라벨, 키). 라벨은 "<접두어> <번호> <라벨>" 로 찍힌다.
|
|
32
|
+
_LIST_ROWS = {
|
|
33
|
+
"discover-projects": ("Project", (("ID", "projectId"), ("root", "projectRoot"),
|
|
34
|
+
("run count", "runCount"), ("active count", "activeCount"),
|
|
35
|
+
("last run at", "lastRunAt"))),
|
|
36
|
+
"list-managers": ("Manager", (("ID", "managerId"), ("created at", "createdAt"),
|
|
37
|
+
("project count", "projectCount"), ("task count", "taskCount"))),
|
|
38
|
+
"list-projects": ("Project", (("ID", "projectId"), ("root", "projectRoot"), ("role", "role"),
|
|
39
|
+
("tags", "tags"), ("linked at", "linkedAt"))),
|
|
40
|
+
"list-tasks": ("Task", (("group", "taskGroup"), ("ID", "taskId"), ("objective", "objective"),
|
|
41
|
+
("progress mode", "progressMode"), ("child count", "childCount"),
|
|
42
|
+
("created at", "createdAt"))),
|
|
43
|
+
}
|
|
24
44
|
|
|
25
45
|
|
|
26
46
|
def render_manager_text(command: str, payload: dict | list[dict]) -> str:
|
|
27
47
|
"""manager 모델 표면에 승인된 식별자와 상태만 투영한다."""
|
|
28
48
|
allowed = {"init", "discover-projects", "new-project", "new-task-group", "new-task",
|
|
29
|
-
"task-assign", "task-note", "task-sync", "task-status", "task-run"
|
|
49
|
+
"task-assign", "task-note", "task-sync", "task-status", "task-run", "task-split",
|
|
50
|
+
"list-managers", "list-projects", "list-tasks", "view"}
|
|
30
51
|
if command not in allowed:
|
|
31
52
|
raise ValueError("unknown manager text purpose")
|
|
32
53
|
rows = [f"Okstra manager {command}\n", line("Status", "ready")]
|
|
33
54
|
if isinstance(payload, list):
|
|
34
55
|
rows.append(line("Result count", len(payload)))
|
|
35
|
-
rows.extend(
|
|
56
|
+
rows.extend(_render_list(command, payload))
|
|
36
57
|
return "".join(rows)
|
|
37
58
|
if command == "task-status":
|
|
38
59
|
rows.extend(_render_manager_status(payload))
|
|
@@ -44,7 +65,9 @@ def render_manager_text(command: str, payload: dict | list[dict]) -> str:
|
|
|
44
65
|
("Work status", "workStatus"), ("Run command", "command"),
|
|
45
66
|
("Backend", "backend"),
|
|
46
67
|
("Worker dispatch backend", "workerDispatchBackend"),
|
|
47
|
-
("Project root", "projectRoot"), ("Context path", "contextPath")
|
|
68
|
+
("Project root", "projectRoot"), ("Context path", "contextPath"),
|
|
69
|
+
("Shell command", "shellCommand"), ("View path", "viewPath"),
|
|
70
|
+
("View URL", "viewUrl")):
|
|
48
71
|
if key in payload:
|
|
49
72
|
rows.append(line(label, payload.get(key)))
|
|
50
73
|
run_args = payload.get("runArgs")
|
|
@@ -64,20 +87,27 @@ def render_manager_text(command: str, payload: dict | list[dict]) -> str:
|
|
|
64
87
|
("Directive body", "body"), ("Created at", "createdAt"),
|
|
65
88
|
("Source", "source")):
|
|
66
89
|
rows.append(line(label, payload.get(key)))
|
|
90
|
+
if command == "task-split":
|
|
91
|
+
briefs = payload.get("briefs") if isinstance(payload.get("briefs"), list) else []
|
|
92
|
+
rows.append(line("Brief count", len(briefs)))
|
|
93
|
+
for index, brief in enumerate(briefs, 1):
|
|
94
|
+
for label, key in (("task key", "taskKey"), ("project ID", "projectId"), ("ticket", "ticketId"),
|
|
95
|
+
("path", "briefPath"), ("status", "briefStatus")):
|
|
96
|
+
rows.append(line(f"Brief {index} {label}", brief.get(key)))
|
|
67
97
|
if command == "task-sync" and "syncedAt" in payload:
|
|
68
98
|
rows.append(line("Synced at", payload.get("syncedAt")))
|
|
69
99
|
return "".join(rows)
|
|
70
100
|
|
|
71
101
|
|
|
72
|
-
def
|
|
102
|
+
def _render_list(command: str, items: list[dict]) -> list[str]:
|
|
103
|
+
prefix, fields = _LIST_ROWS[command]
|
|
73
104
|
rows: list[str] = []
|
|
74
|
-
for index,
|
|
75
|
-
if not isinstance(
|
|
105
|
+
for index, item in enumerate(items, 1):
|
|
106
|
+
if not isinstance(item, dict):
|
|
76
107
|
continue
|
|
77
|
-
for label, key in
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
rows.append(line(f"Project {index} {label}", project.get(key)))
|
|
108
|
+
for label, key in fields:
|
|
109
|
+
value = item.get(key)
|
|
110
|
+
rows.append(line(f"{prefix} {index} {label}", ", ".join(value) if isinstance(value, list) else value))
|
|
81
111
|
return rows
|
|
82
112
|
|
|
83
113
|
|
|
@@ -96,6 +126,7 @@ def _render_manager_status(payload: dict) -> list[str]:
|
|
|
96
126
|
continue
|
|
97
127
|
for label, key in (("task key", "taskKey"), ("run status", "latestRunStatus"),
|
|
98
128
|
("current phase", "currentPhase"), ("work status", "workStatus"),
|
|
129
|
+
("summary", "summaryBucket"), ("ticket", "ticketId"), ("brief path", "briefPath"),
|
|
99
130
|
("report path", "latestReportRecordPath"), ("error", "error")):
|
|
100
131
|
if key in child:
|
|
101
132
|
rows.append(line(f"Child {index} {label}", child.get(key)))
|
|
@@ -156,6 +187,11 @@ _CLI_EPILOG = r"""Usage:
|
|
|
156
187
|
okstra manager task assign --manager-id <id> --task-group <task-group> --task-id <task-id> --project-id <project-id>
|
|
157
188
|
okstra manager task note --manager-id <id> --task-group <task-group> --task-id <task-id> --scope <shared|project> --body <text>
|
|
158
189
|
okstra manager task run --manager-id <id> --project-id <project-id> --task-group <task-group> --task-id <task-id>
|
|
190
|
+
okstra manager task split --manager-id <id> --task-group <task-group> --task-id <task-id> --plan <split-plan.json> [--overwrite]
|
|
191
|
+
okstra manager list managers
|
|
192
|
+
okstra manager list projects --manager-id <id>
|
|
193
|
+
okstra manager list tasks --manager-id <id>
|
|
194
|
+
okstra manager view --manager-id <id>
|
|
159
195
|
|
|
160
196
|
--workspace-root is owned by this command.
|
|
161
197
|
"""
|
|
@@ -198,7 +234,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
198
234
|
task_new.add_argument("--task", action="append", default=[])
|
|
199
235
|
task_new.add_argument("--objective", default="")
|
|
200
236
|
task_new.add_argument("--common-brief", default="")
|
|
201
|
-
task_new.add_argument("--progress-mode", choices=["manual", "auto"], default=
|
|
237
|
+
task_new.add_argument("--progress-mode", choices=["manual", "auto"], default=None)
|
|
202
238
|
|
|
203
239
|
task = sub.add_parser("task")
|
|
204
240
|
task_sub = task.add_subparsers(dest="task_command", required=True)
|
|
@@ -233,6 +269,22 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
233
269
|
run.add_argument("--task-group", required=True)
|
|
234
270
|
run.add_argument("--task-id", required=True)
|
|
235
271
|
run.add_argument("--child-task-id", default=None)
|
|
272
|
+
|
|
273
|
+
split = task_sub.add_parser("split")
|
|
274
|
+
split.add_argument("--manager-id", required=True)
|
|
275
|
+
split.add_argument("--task-group", required=True)
|
|
276
|
+
split.add_argument("--task-id", required=True)
|
|
277
|
+
split.add_argument("--plan", required=True)
|
|
278
|
+
split.add_argument("--overwrite", action="store_true")
|
|
279
|
+
|
|
280
|
+
listing = sub.add_parser("list")
|
|
281
|
+
list_sub = listing.add_subparsers(dest="list_command", required=True)
|
|
282
|
+
list_sub.add_parser("managers")
|
|
283
|
+
for name in ("projects", "tasks"):
|
|
284
|
+
list_sub.add_parser(name).add_argument("--manager-id", required=True)
|
|
285
|
+
|
|
286
|
+
view = sub.add_parser("view")
|
|
287
|
+
view.add_argument("--manager-id", required=True)
|
|
236
288
|
return parser
|
|
237
289
|
|
|
238
290
|
|
|
@@ -322,11 +374,32 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
322
374
|
),
|
|
323
375
|
args.json,
|
|
324
376
|
)
|
|
377
|
+
elif args.command == "task" and args.task_command == "split":
|
|
378
|
+
_emit(
|
|
379
|
+
"task-split",
|
|
380
|
+
split_task(
|
|
381
|
+
home,
|
|
382
|
+
args.manager_id,
|
|
383
|
+
args.task_group,
|
|
384
|
+
args.task_id,
|
|
385
|
+
Path(args.plan),
|
|
386
|
+
overwrite=args.overwrite,
|
|
387
|
+
),
|
|
388
|
+
args.json,
|
|
389
|
+
)
|
|
390
|
+
elif args.command == "list" and args.list_command == "managers":
|
|
391
|
+
_emit("list-managers", list_managers(home), args.json)
|
|
392
|
+
elif args.command == "list" and args.list_command == "projects":
|
|
393
|
+
_emit("list-projects", list_projects(home, args.manager_id), args.json)
|
|
394
|
+
elif args.command == "list" and args.list_command == "tasks":
|
|
395
|
+
_emit("list-tasks", list_tasks(home, args.manager_id), args.json)
|
|
396
|
+
elif args.command == "view":
|
|
397
|
+
_emit("view", write_view(home, args.manager_id), args.json)
|
|
325
398
|
else:
|
|
326
399
|
parser.print_help()
|
|
327
400
|
return 2
|
|
328
401
|
return 0
|
|
329
|
-
except ManagerError as exc:
|
|
402
|
+
except (ManagerError, ManagerPathError) as exc:
|
|
330
403
|
print(f"okstra manager: {exc}", file=sys.stderr)
|
|
331
404
|
return 2
|
|
332
405
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Build manager child launch packets and context documents."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import shlex
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from okstra_ctl.jsonl import append_jsonl, read_jsonl
|
|
@@ -15,12 +16,16 @@ from .manager_paths import (
|
|
|
15
16
|
task_manifest_path,
|
|
16
17
|
)
|
|
17
18
|
from .manager_store import ManagerError, _now_iso, _read_json, _read_json_default, _write_json
|
|
19
|
+
from .manager_sync import _resolve_task_root_read_only
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
WORKER_DISPATCH_BACKEND = "subagent"
|
|
21
|
-
# child lead 는
|
|
22
|
-
#
|
|
23
|
-
|
|
23
|
+
# child lead 는 설치본 런처(`okstra.sh`)가 새 host 프로세스로 띄운다. `okstra run`
|
|
24
|
+
# 은 lead host 에 `--launch-only` 를 붙여 task 인자와 `--directive` 를 버리므로
|
|
25
|
+
# child 맥락이 전달되지 않는다(run.mts 의 launch-lead 분기). 런처는 prepare 까지
|
|
26
|
+
# 거친 뒤 `launch-plan --entry-mode spawn-process` 로 lead 를 띄운다.
|
|
27
|
+
CHILD_LEAD_BACKEND = "spawn-process"
|
|
28
|
+
LAUNCHER_RELATIVE = Path("bin") / "okstra.sh"
|
|
24
29
|
LAUNCH_STATUS_PREPARED = "prepared"
|
|
25
30
|
|
|
26
31
|
|
|
@@ -110,9 +115,14 @@ def _render_context(
|
|
|
110
115
|
"",
|
|
111
116
|
child.get("assignment") or "",
|
|
112
117
|
"",
|
|
113
|
-
"## Manager Directives",
|
|
114
|
-
"",
|
|
115
118
|
]
|
|
119
|
+
if child.get("scope"):
|
|
120
|
+
# `task split` 이 만든 하위 태스크. 이 프로젝트에서 할 범위만 적는다.
|
|
121
|
+
lines.extend(["## Project Scope", "", f"- ticket: {child.get('ticketId') or ''}",
|
|
122
|
+
f"- brief: {child.get('briefPath') or ''}", ""])
|
|
123
|
+
lines.extend(f"- in scope: {item}" for item in child["scope"])
|
|
124
|
+
lines.append("")
|
|
125
|
+
lines.extend(["## Manager Directives", ""])
|
|
116
126
|
for row in directives:
|
|
117
127
|
lines.append(f"- [{row.get('scope')}] {row.get('body')}")
|
|
118
128
|
if not directives:
|
|
@@ -214,6 +224,28 @@ def build_launch_packet(
|
|
|
214
224
|
),
|
|
215
225
|
encoding="utf-8",
|
|
216
226
|
)
|
|
227
|
+
command = str(Path(home) / LAUNCHER_RELATIVE)
|
|
228
|
+
run_args = [
|
|
229
|
+
"--project-root",
|
|
230
|
+
project_root,
|
|
231
|
+
"--project-id",
|
|
232
|
+
project_id,
|
|
233
|
+
"--task-group",
|
|
234
|
+
str(child.get("taskGroup") or task_group),
|
|
235
|
+
"--task-id",
|
|
236
|
+
child_task_id_value,
|
|
237
|
+
]
|
|
238
|
+
# `task split` 이 기록한 브리프·권장 phase 를 넘겨 런처가 첫 실행에서 다시 묻지 않게 한다.
|
|
239
|
+
# phase 는 하위 태스크가 아직 없을 때만 넘긴다. 이미 있으면 런처가 manifest 에서 다음
|
|
240
|
+
# phase 를 채워야 하는데, 두 값이 다 주어지면 그 채우기를 건너뛴다(autofill_from_manifest).
|
|
241
|
+
if child.get("recommendedPhase") and _resolve_task_root_read_only(Path(project_root), target_task_key) is None:
|
|
242
|
+
run_args.extend(["--task-type", str(child["recommendedPhase"])])
|
|
243
|
+
if child.get("briefPath"):
|
|
244
|
+
run_args.extend(["--task-brief", str(child["briefPath"])])
|
|
245
|
+
run_args += [
|
|
246
|
+
"--directive",
|
|
247
|
+
f"Read manager child context: {context_path}",
|
|
248
|
+
]
|
|
217
249
|
packet = {
|
|
218
250
|
"managerId": manager_id,
|
|
219
251
|
"taskGroup": task_group,
|
|
@@ -224,19 +256,9 @@ def build_launch_packet(
|
|
|
224
256
|
"workerDispatchBackend": WORKER_DISPATCH_BACKEND,
|
|
225
257
|
"projectRoot": project_root,
|
|
226
258
|
"contextPath": str(context_path),
|
|
227
|
-
"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
project_root,
|
|
231
|
-
"--project-id",
|
|
232
|
-
project_id,
|
|
233
|
-
"--task-group",
|
|
234
|
-
str(child.get("taskGroup") or task_group),
|
|
235
|
-
"--task-id",
|
|
236
|
-
child_task_id_value,
|
|
237
|
-
"--directive",
|
|
238
|
-
f"Read manager child context: {context_path}",
|
|
239
|
-
],
|
|
259
|
+
"command": command,
|
|
260
|
+
"runArgs": run_args,
|
|
261
|
+
"shellCommand": shlex.join([command, *run_args]),
|
|
240
262
|
"createdAt": created_at,
|
|
241
263
|
}
|
|
242
264
|
_record_launch_prepared(
|