okstra 0.102.4 → 0.103.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.kr.md +3 -2
- package/README.md +3 -2
- package/docs/for-ai/skills/okstra-brief.md +18 -2
- package/docs/for-ai/skills/okstra-run.md +9 -1
- package/docs/kr/cli.md +1 -0
- package/docs/project-structure-overview.md +9 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/profiles/_common-contract.md +4 -0
- package/runtime/prompts/profiles/error-analysis.md +2 -0
- package/runtime/prompts/profiles/improvement-discovery.md +2 -0
- package/runtime/prompts/profiles/requirements-discovery.md +4 -0
- package/runtime/prompts/wizard/prompts.ko.json +1 -1
- package/runtime/python/okstra_ctl/manager_cli.py +231 -0
- package/runtime/python/okstra_ctl/manager_launch.py +198 -0
- package/runtime/python/okstra_ctl/manager_paths.py +98 -0
- package/runtime/python/okstra_ctl/manager_store.py +318 -0
- package/runtime/python/okstra_ctl/manager_sync.py +144 -0
- package/runtime/python/okstra_ctl/wizard.py +105 -15
- package/runtime/skills/okstra-brief/SKILL.md +32 -7
- package/runtime/skills/okstra-manager/SKILL.md +44 -0
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- package/runtime/templates/reports/brief.template.md +25 -0
- package/runtime/validators/validate-brief.py +128 -0
- package/src/cli-registry.mjs +7 -0
- package/src/commands/manager.mjs +57 -0
- package/src/lib/skill-catalog.mjs +1 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Build manager child launch packets and context documents."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Mapping
|
|
6
|
+
|
|
7
|
+
from okstra_ctl.jsonl import read_jsonl
|
|
8
|
+
|
|
9
|
+
from .manager_paths import (
|
|
10
|
+
child_context_path,
|
|
11
|
+
children_json_path,
|
|
12
|
+
directives_jsonl_path,
|
|
13
|
+
projects_json_path,
|
|
14
|
+
snapshots_json_path,
|
|
15
|
+
task_manifest_path,
|
|
16
|
+
)
|
|
17
|
+
from .manager_store import ManagerError, _now_iso, _read_json, _read_json_default
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def select_child_lead_backend(env: Mapping[str, str]) -> str:
|
|
21
|
+
return "tmux-child-lead" if str(env.get("TMUX") or "").strip() else "subagent-child-lead"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_project(projects: dict, project_id: str) -> dict:
|
|
25
|
+
for project in projects.get("projects", []):
|
|
26
|
+
if isinstance(project, dict) and project.get("projectId") == project_id:
|
|
27
|
+
return project
|
|
28
|
+
raise ManagerError(f"unknown project membership: {project_id}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _target_task_key(project_id: str, task_group: str, task_id: str) -> str:
|
|
32
|
+
return f"{project_id}:{task_group}:{task_id}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _find_child(children: dict, project_id: str, task_group: str, task_id: str) -> dict:
|
|
36
|
+
target_key = _target_task_key(project_id, task_group, task_id)
|
|
37
|
+
for child in children.get("children", []):
|
|
38
|
+
if not isinstance(child, dict):
|
|
39
|
+
continue
|
|
40
|
+
if child.get("taskKey") == target_key:
|
|
41
|
+
return child
|
|
42
|
+
if (
|
|
43
|
+
child.get("projectId") == project_id
|
|
44
|
+
and child.get("taskGroup") == task_group
|
|
45
|
+
and child.get("taskId") == task_id
|
|
46
|
+
):
|
|
47
|
+
return child
|
|
48
|
+
raise ManagerError(f"planned child not found for task key: {target_key}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _validate_child_task_key(child: dict, target_task_key: str) -> None:
|
|
52
|
+
persisted_key = str(child.get("taskKey") or "").strip()
|
|
53
|
+
if persisted_key and persisted_key != target_task_key:
|
|
54
|
+
raise ManagerError(f"child taskKey mismatch: {persisted_key!r}, expected {target_task_key!r}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _validated_project_root(project: dict) -> str:
|
|
58
|
+
raw_root = project.get("projectRoot")
|
|
59
|
+
if not isinstance(raw_root, str) or not raw_root.strip():
|
|
60
|
+
raise ManagerError(f"projectRoot is required for project: {project.get('projectId') or ''}")
|
|
61
|
+
root = Path(raw_root).resolve()
|
|
62
|
+
if not root.is_dir():
|
|
63
|
+
raise ManagerError(f"projectRoot is not a directory for project: {project.get('projectId') or ''}: {root}")
|
|
64
|
+
return str(root)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _directives_for_project(rows: list[dict], project_id: str) -> list[dict]:
|
|
68
|
+
directives: list[dict] = []
|
|
69
|
+
for row in rows:
|
|
70
|
+
if row.get("scope") == "shared":
|
|
71
|
+
directives.append(row)
|
|
72
|
+
elif row.get("scope") == "project" and row.get("projectId") == project_id:
|
|
73
|
+
directives.append(row)
|
|
74
|
+
return directives
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _sibling_snapshots(snapshot: dict, target_task_key: str) -> list[dict]:
|
|
78
|
+
return [
|
|
79
|
+
row
|
|
80
|
+
for row in snapshot.get("children", [])
|
|
81
|
+
if isinstance(row, dict) and row.get("taskKey") != target_task_key
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _render_context(
|
|
86
|
+
*,
|
|
87
|
+
manifest: dict,
|
|
88
|
+
child: dict,
|
|
89
|
+
child_task_key: str,
|
|
90
|
+
directives: list[dict],
|
|
91
|
+
siblings: list[dict],
|
|
92
|
+
) -> str:
|
|
93
|
+
lines = [
|
|
94
|
+
"# Okstra Manager Child Context",
|
|
95
|
+
"",
|
|
96
|
+
f"- manager-id: {manifest['managerId']}",
|
|
97
|
+
f"- task-group: {manifest['taskGroup']}",
|
|
98
|
+
f"- manager task-id: {manifest['taskId']}",
|
|
99
|
+
f"- child task-key: {child_task_key}",
|
|
100
|
+
f"- objective: {manifest.get('objective') or ''}",
|
|
101
|
+
f"- common brief: {manifest.get('commonBriefPath') or ''}",
|
|
102
|
+
"",
|
|
103
|
+
"## Assignment",
|
|
104
|
+
"",
|
|
105
|
+
f"- role: {child.get('role') or ''}",
|
|
106
|
+
f"- tags: {', '.join(child.get('tags') or [])}",
|
|
107
|
+
"",
|
|
108
|
+
child.get("assignment") or "",
|
|
109
|
+
"",
|
|
110
|
+
"## Manager Directives",
|
|
111
|
+
"",
|
|
112
|
+
]
|
|
113
|
+
for row in directives:
|
|
114
|
+
lines.append(f"- [{row.get('scope')}] {row.get('body')}")
|
|
115
|
+
if not directives:
|
|
116
|
+
lines.append("- none")
|
|
117
|
+
lines.extend(
|
|
118
|
+
[
|
|
119
|
+
"",
|
|
120
|
+
"## Sibling Project Snapshots",
|
|
121
|
+
"",
|
|
122
|
+
"Sibling synced reports are read-side source material.",
|
|
123
|
+
"Do not write into sibling project roots.",
|
|
124
|
+
"Project-local files remain the edit boundary for this child task.",
|
|
125
|
+
"",
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
for row in siblings:
|
|
129
|
+
lines.append(
|
|
130
|
+
f"- {row.get('taskKey')}: {row.get('latestRunStatus') or 'unknown'} {row.get('latestReportPath') or ''}"
|
|
131
|
+
)
|
|
132
|
+
if not siblings:
|
|
133
|
+
lines.append("- none")
|
|
134
|
+
lines.append("")
|
|
135
|
+
return "\n".join(lines)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_launch_packet(
|
|
139
|
+
home: Path,
|
|
140
|
+
manager_id: str,
|
|
141
|
+
task_group: str,
|
|
142
|
+
task_id: str,
|
|
143
|
+
project_id: str,
|
|
144
|
+
*,
|
|
145
|
+
child_task_id: str | None = None,
|
|
146
|
+
env: Mapping[str, str] | None = None,
|
|
147
|
+
now: str | None = None,
|
|
148
|
+
) -> dict:
|
|
149
|
+
manifest = _read_json(task_manifest_path(home, manager_id, task_group, task_id))
|
|
150
|
+
projects = _read_json_default(projects_json_path(home, manager_id), {"projects": []})
|
|
151
|
+
children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
152
|
+
snapshot = _read_json_default(snapshots_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
153
|
+
directives = read_jsonl(directives_jsonl_path(home, manager_id, task_group, task_id))
|
|
154
|
+
project = _find_project(projects, project_id)
|
|
155
|
+
effective_child_task_id = child_task_id or task_id
|
|
156
|
+
child = _find_child(children, project_id, task_group, effective_child_task_id)
|
|
157
|
+
child_task_id_value = str(child.get("taskId") or effective_child_task_id)
|
|
158
|
+
target_task_key = _target_task_key(project_id, task_group, child_task_id_value)
|
|
159
|
+
_validate_child_task_key(child, target_task_key)
|
|
160
|
+
project_root = _validated_project_root(project)
|
|
161
|
+
backend = select_child_lead_backend(env or {})
|
|
162
|
+
context_path = child_context_path(home, manager_id, task_group, task_id, project_id, child_task_id_value)
|
|
163
|
+
context_path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
context_path.write_text(
|
|
165
|
+
_render_context(
|
|
166
|
+
manifest=manifest,
|
|
167
|
+
child=child,
|
|
168
|
+
child_task_key=target_task_key,
|
|
169
|
+
directives=_directives_for_project(directives, project_id),
|
|
170
|
+
siblings=_sibling_snapshots(snapshot, target_task_key),
|
|
171
|
+
),
|
|
172
|
+
encoding="utf-8",
|
|
173
|
+
)
|
|
174
|
+
return {
|
|
175
|
+
"managerId": manager_id,
|
|
176
|
+
"taskGroup": task_group,
|
|
177
|
+
"taskId": task_id,
|
|
178
|
+
"projectId": project_id,
|
|
179
|
+
"taskKey": target_task_key,
|
|
180
|
+
"backend": backend,
|
|
181
|
+
"workerDispatchBackend": "subagent",
|
|
182
|
+
"projectRoot": project_root,
|
|
183
|
+
"contextPath": str(context_path),
|
|
184
|
+
"runArgs": [
|
|
185
|
+
"run",
|
|
186
|
+
"--project-root",
|
|
187
|
+
project_root,
|
|
188
|
+
"--project-id",
|
|
189
|
+
project_id,
|
|
190
|
+
"--task-group",
|
|
191
|
+
str(child.get("taskGroup") or task_group),
|
|
192
|
+
"--task-id",
|
|
193
|
+
child_task_id_value,
|
|
194
|
+
"--directive",
|
|
195
|
+
f"Read manager child context: {context_path}",
|
|
196
|
+
],
|
|
197
|
+
"createdAt": now or _now_iso(),
|
|
198
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Pure path computation for okstra manager state."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from okstra_project import slugify
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ManagerPathError(Exception):
|
|
11
|
+
"""Invalid manager identity or task identity."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _require_id(value: str, label: str) -> str:
|
|
15
|
+
cleaned = (value or "").strip()
|
|
16
|
+
if not cleaned:
|
|
17
|
+
raise ManagerPathError(f"{label} is required")
|
|
18
|
+
if not any(ch.isalnum() for ch in cleaned):
|
|
19
|
+
raise ManagerPathError(f"{label} must contain at least one alphanumeric character")
|
|
20
|
+
return cleaned
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _slug_segment(value: str, label: str) -> str:
|
|
24
|
+
segment = slugify(_require_id(value, label))
|
|
25
|
+
if not segment:
|
|
26
|
+
raise ManagerPathError(f"{label} must contain at least one ASCII alphanumeric character")
|
|
27
|
+
return segment
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_manager_id(value: str) -> str:
|
|
31
|
+
cleaned = (value or "").strip()
|
|
32
|
+
if not cleaned:
|
|
33
|
+
raise ManagerPathError("manager-id is required")
|
|
34
|
+
if cleaned in {".", ".."}:
|
|
35
|
+
raise ManagerPathError("manager-id cannot be absolute or dot path segment")
|
|
36
|
+
if os.path.isabs(cleaned):
|
|
37
|
+
raise ManagerPathError("manager-id cannot be absolute or dot path segment")
|
|
38
|
+
if any(ch in cleaned for ch in ("/", "\\")):
|
|
39
|
+
raise ManagerPathError("manager-id cannot contain path separators")
|
|
40
|
+
if not any(ch.isalnum() for ch in cleaned):
|
|
41
|
+
raise ManagerPathError("manager-id must contain at least one alphanumeric character")
|
|
42
|
+
return cleaned
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def canonical_manager_id(manager_id: str) -> str:
|
|
46
|
+
"""Return validated, canonical manager identifier used for both path and payload."""
|
|
47
|
+
return _require_manager_id(manager_id)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def manager_root(home: Path, manager_id: str) -> Path:
|
|
51
|
+
return Path(home) / "managers" / canonical_manager_id(manager_id)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def task_root(home: Path, manager_id: str, task_group: str, task_id: str) -> Path:
|
|
55
|
+
group_segment = _slug_segment(task_group, "task-group")
|
|
56
|
+
task_segment = _slug_segment(task_id, "task-id")
|
|
57
|
+
return manager_root(home, manager_id) / "task-groups" / group_segment / task_segment
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def manager_json_path(home: Path, manager_id: str) -> Path:
|
|
61
|
+
return manager_root(home, manager_id) / "manager.json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def projects_json_path(home: Path, manager_id: str) -> Path:
|
|
65
|
+
return manager_root(home, manager_id) / "projects.json"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def task_manifest_path(home: Path, manager_id: str, task_group: str, task_id: str) -> Path:
|
|
69
|
+
return task_root(home, manager_id, task_group, task_id) / "manifest.json"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def children_json_path(home: Path, manager_id: str, task_group: str, task_id: str) -> Path:
|
|
73
|
+
return task_root(home, manager_id, task_group, task_id) / "children.json"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def directives_jsonl_path(home: Path, manager_id: str, task_group: str, task_id: str) -> Path:
|
|
77
|
+
return task_root(home, manager_id, task_group, task_id) / "directives.jsonl"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def snapshots_json_path(home: Path, manager_id: str, task_group: str, task_id: str) -> Path:
|
|
81
|
+
return task_root(home, manager_id, task_group, task_id) / "snapshots.json"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def events_jsonl_path(home: Path, manager_id: str, task_group: str, task_id: str) -> Path:
|
|
85
|
+
return task_root(home, manager_id, task_group, task_id) / "events.jsonl"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def child_context_path(
|
|
89
|
+
home: Path,
|
|
90
|
+
manager_id: str,
|
|
91
|
+
task_group: str,
|
|
92
|
+
task_id: str,
|
|
93
|
+
project_id: str,
|
|
94
|
+
child_task_id: str,
|
|
95
|
+
) -> Path:
|
|
96
|
+
safe_project = slugify(_require_id(project_id, "project-id"))
|
|
97
|
+
safe_task = slugify(_require_id(child_task_id, "task-id"))
|
|
98
|
+
return task_root(home, manager_id, task_group, task_id) / "child-context" / f"{safe_project}-{safe_task}.md"
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Manager-owned state mutations for cross-project okstra orchestration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from okstra_project import project_json_path, upsert_project_json
|
|
10
|
+
|
|
11
|
+
from okstra_ctl.jsonl import append_jsonl
|
|
12
|
+
|
|
13
|
+
from .manager_paths import (
|
|
14
|
+
ManagerPathError,
|
|
15
|
+
canonical_manager_id,
|
|
16
|
+
children_json_path,
|
|
17
|
+
directives_jsonl_path,
|
|
18
|
+
events_jsonl_path,
|
|
19
|
+
manager_json_path,
|
|
20
|
+
manager_root,
|
|
21
|
+
projects_json_path,
|
|
22
|
+
task_manifest_path,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ManagerError(Exception):
|
|
27
|
+
"""Manager state validation or I/O failure."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now_iso() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_json(path: Path, payload: dict) -> None:
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
37
|
+
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
38
|
+
os.replace(tmp, path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_json(path: Path) -> dict:
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
raise ManagerError(f"manager file not found: {path}")
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
except json.JSONDecodeError as exc:
|
|
47
|
+
raise ManagerError(f"failed to parse {path}: {exc}") from exc
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
raise ManagerError(f"expected JSON object in {path}")
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_json_default(path: Path, default: dict) -> dict:
|
|
54
|
+
if not path.is_file():
|
|
55
|
+
return default
|
|
56
|
+
return _read_json(path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _project_entry(projects: dict, project_id: str) -> dict | None:
|
|
60
|
+
for entry in projects.get("projects", []):
|
|
61
|
+
if isinstance(entry, dict) and entry.get("projectId") == project_id:
|
|
62
|
+
return entry
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_projects(home: Path, manager_id: str) -> dict:
|
|
67
|
+
return _read_json_default(projects_json_path(home, manager_id), {"projects": []})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def init_manager(home: Path, manager_id: str, *, now: str | None = None) -> dict:
|
|
71
|
+
try:
|
|
72
|
+
canonical_id = canonical_manager_id(manager_id)
|
|
73
|
+
root = manager_root(home, canonical_id)
|
|
74
|
+
except ManagerPathError as exc:
|
|
75
|
+
raise ManagerError(str(exc)) from exc
|
|
76
|
+
path = manager_json_path(home, canonical_id)
|
|
77
|
+
if path.is_file():
|
|
78
|
+
return _read_json(path)
|
|
79
|
+
payload = {
|
|
80
|
+
"managerId": canonical_id,
|
|
81
|
+
"schemaVersion": 1,
|
|
82
|
+
"createdAt": now or _now_iso(),
|
|
83
|
+
}
|
|
84
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
_write_json(path, payload)
|
|
86
|
+
return payload
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_manager(home: Path, manager_id: str) -> dict:
|
|
90
|
+
try:
|
|
91
|
+
path = manager_json_path(home, canonical_manager_id(manager_id))
|
|
92
|
+
except ManagerPathError as exc:
|
|
93
|
+
raise ManagerError(str(exc)) from exc
|
|
94
|
+
return _read_json(path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def discover_projects(home: Path) -> list[dict]:
|
|
98
|
+
base = Path(home) / "projects"
|
|
99
|
+
if not base.is_dir():
|
|
100
|
+
return []
|
|
101
|
+
items: list[dict] = []
|
|
102
|
+
for meta in sorted(base.glob("*/meta.json")):
|
|
103
|
+
try:
|
|
104
|
+
data = json.loads(meta.read_text(encoding="utf-8"))
|
|
105
|
+
except (OSError, json.JSONDecodeError):
|
|
106
|
+
continue
|
|
107
|
+
if isinstance(data, dict):
|
|
108
|
+
items.append(
|
|
109
|
+
{
|
|
110
|
+
"projectId": str(data.get("projectId") or ""),
|
|
111
|
+
"projectRoot": str(data.get("projectRoot") or ""),
|
|
112
|
+
"runCount": int(data.get("runCount", 0)),
|
|
113
|
+
"activeCount": int(data.get("activeCount", 0)),
|
|
114
|
+
"lastRunAt": str(data.get("lastRunAt") or ""),
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
return [item for item in items if item["projectId"]]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def link_project(
|
|
121
|
+
home: Path,
|
|
122
|
+
manager_id: str,
|
|
123
|
+
project_id: str,
|
|
124
|
+
project_root: Path,
|
|
125
|
+
*,
|
|
126
|
+
role: str,
|
|
127
|
+
tags: list[str],
|
|
128
|
+
now: str | None = None,
|
|
129
|
+
) -> dict:
|
|
130
|
+
load_manager(home, manager_id)
|
|
131
|
+
root = Path(project_root).resolve()
|
|
132
|
+
project_json = project_json_path(root)
|
|
133
|
+
if project_json.is_file():
|
|
134
|
+
try:
|
|
135
|
+
existing = json.loads(project_json.read_text(encoding="utf-8"))
|
|
136
|
+
except json.JSONDecodeError as exc:
|
|
137
|
+
raise ManagerError(f"failed to parse {project_json}: {exc}") from exc
|
|
138
|
+
existing_id = str(existing.get("projectId") or "")
|
|
139
|
+
if existing_id != project_id:
|
|
140
|
+
raise ManagerError(
|
|
141
|
+
f"projectId mismatch: {project_json} has {existing_id!r}, expected {project_id!r}"
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
try:
|
|
145
|
+
upsert_project_json(root, project_id, now=now)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
raise ManagerError(str(exc)) from exc
|
|
148
|
+
payload = _load_projects(home, manager_id)
|
|
149
|
+
entry = _project_entry(payload, project_id)
|
|
150
|
+
next_entry = {
|
|
151
|
+
"projectId": project_id,
|
|
152
|
+
"projectRoot": str(root),
|
|
153
|
+
"role": role,
|
|
154
|
+
"tags": list(tags),
|
|
155
|
+
"linkedAt": now or _now_iso(),
|
|
156
|
+
}
|
|
157
|
+
if entry is None:
|
|
158
|
+
payload["projects"].append(next_entry)
|
|
159
|
+
else:
|
|
160
|
+
entry.update(next_entry)
|
|
161
|
+
payload["projects"].sort(key=lambda item: item.get("projectId", ""))
|
|
162
|
+
_write_json(projects_json_path(home, manager_id), payload)
|
|
163
|
+
return payload
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def create_task_group(home: Path, manager_id: str, task_group: str, *, now: str | None = None) -> dict:
|
|
167
|
+
load_manager(home, manager_id)
|
|
168
|
+
group_dir = task_manifest_path(home, manager_id, task_group, "group-marker").parents[1]
|
|
169
|
+
group_dir.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
return {"managerId": manager_id, "taskGroup": task_group, "createdAt": now or _now_iso()}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def create_task(
|
|
174
|
+
home: Path,
|
|
175
|
+
manager_id: str,
|
|
176
|
+
task_group: str,
|
|
177
|
+
task_id: str,
|
|
178
|
+
child_specs: list[dict],
|
|
179
|
+
*,
|
|
180
|
+
objective: str = "",
|
|
181
|
+
common_brief_path: str = "",
|
|
182
|
+
progress_mode: str = "manual",
|
|
183
|
+
now: str | None = None,
|
|
184
|
+
) -> dict:
|
|
185
|
+
projects = _load_projects(home, manager_id)
|
|
186
|
+
when = now or _now_iso()
|
|
187
|
+
manifest = {
|
|
188
|
+
"managerId": manager_id,
|
|
189
|
+
"taskGroup": task_group,
|
|
190
|
+
"taskId": task_id,
|
|
191
|
+
"progressMode": progress_mode,
|
|
192
|
+
"commonBriefPath": common_brief_path,
|
|
193
|
+
"objective": objective,
|
|
194
|
+
"createdAt": when,
|
|
195
|
+
}
|
|
196
|
+
children = []
|
|
197
|
+
for spec in child_specs:
|
|
198
|
+
project_id = str(spec.get("projectId") or "")
|
|
199
|
+
child_task_id = str(spec.get("taskId") or task_id)
|
|
200
|
+
if _project_entry(projects, project_id) is None:
|
|
201
|
+
raise ManagerError(f"unknown project membership: {project_id}")
|
|
202
|
+
children.append(
|
|
203
|
+
{
|
|
204
|
+
"projectId": project_id,
|
|
205
|
+
"taskGroup": task_group,
|
|
206
|
+
"taskId": child_task_id,
|
|
207
|
+
"taskKey": f"{project_id}:{task_group}:{child_task_id}",
|
|
208
|
+
"role": "",
|
|
209
|
+
"tags": [],
|
|
210
|
+
"assignment": "",
|
|
211
|
+
"launch": {
|
|
212
|
+
"status": "planned",
|
|
213
|
+
"childLeadBackend": "auto",
|
|
214
|
+
"workerDispatchBackend": "subagent",
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
payload = {"manifest": manifest, "children": {"children": children}}
|
|
219
|
+
_write_json(task_manifest_path(home, manager_id, task_group, task_id), manifest)
|
|
220
|
+
_write_json(children_json_path(home, manager_id, task_group, task_id), payload["children"])
|
|
221
|
+
append_jsonl(
|
|
222
|
+
events_jsonl_path(home, manager_id, task_group, task_id),
|
|
223
|
+
{"event": "task-created", "createdAt": when},
|
|
224
|
+
)
|
|
225
|
+
return payload
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _load_children(home: Path, manager_id: str, task_group: str, task_id: str) -> dict:
|
|
229
|
+
return _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _child_task_key(project_id: str, task_group: str, child_task_id: str) -> str:
|
|
233
|
+
return f"{project_id}:{task_group}:{child_task_id}"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _matches_child_task(child: dict, project_id: str, task_group: str, child_task_id: str) -> bool:
|
|
237
|
+
target_key = _child_task_key(project_id, task_group, child_task_id)
|
|
238
|
+
if child.get("taskKey") == target_key:
|
|
239
|
+
return True
|
|
240
|
+
return (
|
|
241
|
+
child.get("projectId") == project_id
|
|
242
|
+
and child.get("taskGroup") == task_group
|
|
243
|
+
and child.get("taskId") == child_task_id
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _select_child_for_assignment(
|
|
248
|
+
children: list,
|
|
249
|
+
project_id: str,
|
|
250
|
+
task_group: str,
|
|
251
|
+
child_task_id: str | None,
|
|
252
|
+
) -> dict:
|
|
253
|
+
if child_task_id is not None:
|
|
254
|
+
for child in children:
|
|
255
|
+
if isinstance(child, dict) and _matches_child_task(
|
|
256
|
+
child,
|
|
257
|
+
project_id,
|
|
258
|
+
task_group,
|
|
259
|
+
child_task_id,
|
|
260
|
+
):
|
|
261
|
+
return child
|
|
262
|
+
raise ManagerError(
|
|
263
|
+
f"planned child not found for task key: {_child_task_key(project_id, task_group, child_task_id)}"
|
|
264
|
+
)
|
|
265
|
+
matches = [child for child in children if isinstance(child, dict) and child.get("projectId") == project_id]
|
|
266
|
+
if len(matches) == 1:
|
|
267
|
+
return matches[0]
|
|
268
|
+
if len(matches) > 1:
|
|
269
|
+
raise ManagerError(f"ambiguous child assignment for project: {project_id}; pass child_task_id")
|
|
270
|
+
raise ManagerError(f"planned child not found for project: {project_id}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def assign_child(
|
|
274
|
+
home: Path,
|
|
275
|
+
manager_id: str,
|
|
276
|
+
task_group: str,
|
|
277
|
+
task_id: str,
|
|
278
|
+
project_id: str,
|
|
279
|
+
*,
|
|
280
|
+
role: str,
|
|
281
|
+
tags: list[str],
|
|
282
|
+
assignment: str,
|
|
283
|
+
child_task_id: str | None = None,
|
|
284
|
+
) -> dict:
|
|
285
|
+
payload = _load_children(home, manager_id, task_group, task_id)
|
|
286
|
+
child = _select_child_for_assignment(payload["children"], project_id, task_group, child_task_id)
|
|
287
|
+
child["role"] = role
|
|
288
|
+
child["tags"] = list(tags)
|
|
289
|
+
child["assignment"] = assignment
|
|
290
|
+
_write_json(children_json_path(home, manager_id, task_group, task_id), payload)
|
|
291
|
+
return payload
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def append_directive(
|
|
295
|
+
home: Path,
|
|
296
|
+
manager_id: str,
|
|
297
|
+
task_group: str,
|
|
298
|
+
task_id: str,
|
|
299
|
+
*,
|
|
300
|
+
scope: str,
|
|
301
|
+
body: str,
|
|
302
|
+
project_id: str = "",
|
|
303
|
+
now: str | None = None,
|
|
304
|
+
source: str = "user",
|
|
305
|
+
) -> dict:
|
|
306
|
+
if scope not in {"shared", "project"}:
|
|
307
|
+
raise ManagerError("scope must be shared or project")
|
|
308
|
+
if scope == "project" and not project_id:
|
|
309
|
+
raise ManagerError("project directive requires project-id")
|
|
310
|
+
row = {
|
|
311
|
+
"scope": scope,
|
|
312
|
+
"projectId": project_id if scope == "project" else "",
|
|
313
|
+
"body": body,
|
|
314
|
+
"createdAt": now or _now_iso(),
|
|
315
|
+
"source": source,
|
|
316
|
+
}
|
|
317
|
+
append_jsonl(directives_jsonl_path(home, manager_id, task_group, task_id), row)
|
|
318
|
+
return row
|