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