@1mancompany/onemancompany 0.7.12 → 0.7.19
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/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/agents/common_tools.py +5 -0
- package/src/onemancompany/agents/product_workspace_tools.py +113 -0
- package/src/onemancompany/core/config.py +1 -0
- package/src/onemancompany/core/product.py +9 -0
- package/src/onemancompany/core/product_workspace.py +248 -0
- package/src/onemancompany/core/project_archive.py +48 -0
- package/src/onemancompany/core/vessel.py +37 -0
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -1916,6 +1916,11 @@ def _register_all_internal_tools() -> None:
|
|
|
1916
1916
|
for t in _product_tools:
|
|
1917
1917
|
tool_registry.register(t, ToolMeta(name=t.name, category="base"))
|
|
1918
1918
|
|
|
1919
|
+
# Product workspace tools — promote_to_product
|
|
1920
|
+
from onemancompany.agents.product_workspace_tools import PRODUCT_WORKSPACE_TOOLS as _pw_tools
|
|
1921
|
+
for t in _pw_tools:
|
|
1922
|
+
tool_registry.register(t, ToolMeta(name=t.name, category="base"))
|
|
1923
|
+
|
|
1919
1924
|
# Tree tools self-register on import
|
|
1920
1925
|
from onemancompany.agents import tree_tools as _tt # noqa: F401
|
|
1921
1926
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Product workspace tools for LangChain agents.
|
|
2
|
+
|
|
3
|
+
Provides promote_to_product — merges a project worktree back into the shared
|
|
4
|
+
product workspace (main branch).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from langchain_core.tools import tool
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
from onemancompany.core import product_workspace as pw
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Context resolver
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_product_workspace() -> tuple[Path, Path, str]:
|
|
23
|
+
"""Resolve the current execution context to find the product workspace.
|
|
24
|
+
|
|
25
|
+
Returns (workspace_dir, worktree_path, project_id).
|
|
26
|
+
"""
|
|
27
|
+
from onemancompany.core.vessel import _current_vessel
|
|
28
|
+
from onemancompany.core.project_archive import load_named_project
|
|
29
|
+
from onemancompany.core.product import find_slug_by_product_id
|
|
30
|
+
from onemancompany.core.config import PRODUCTS_DIR, PROJECTS_DIR, PRODUCT_WORKTREE_DIR_NAME
|
|
31
|
+
|
|
32
|
+
vessel = _current_vessel.get()
|
|
33
|
+
if not vessel:
|
|
34
|
+
raise ValueError("No active vessel context")
|
|
35
|
+
|
|
36
|
+
# Get project_id from the running node
|
|
37
|
+
project_id = ""
|
|
38
|
+
if hasattr(vessel, "_running_node") and vessel._running_node:
|
|
39
|
+
project_id = vessel._running_node.project_id or ""
|
|
40
|
+
if not project_id:
|
|
41
|
+
raise ValueError("Current task is not part of a project")
|
|
42
|
+
|
|
43
|
+
proj_doc = load_named_project(project_id)
|
|
44
|
+
if not proj_doc:
|
|
45
|
+
raise ValueError(f"Project {project_id} not found")
|
|
46
|
+
|
|
47
|
+
product_id = proj_doc.get("product_id", "")
|
|
48
|
+
if not product_id:
|
|
49
|
+
raise ValueError(f"Project {project_id} is not linked to a product")
|
|
50
|
+
|
|
51
|
+
slug = find_slug_by_product_id(product_id)
|
|
52
|
+
if not slug:
|
|
53
|
+
raise ValueError(f"Product not found for id={product_id}")
|
|
54
|
+
|
|
55
|
+
workspace_dir = PRODUCTS_DIR / slug / "workspace"
|
|
56
|
+
worktree_path = PROJECTS_DIR / project_id / PRODUCT_WORKTREE_DIR_NAME
|
|
57
|
+
|
|
58
|
+
if not workspace_dir.exists():
|
|
59
|
+
raise ValueError(f"Product workspace not initialized for {slug}")
|
|
60
|
+
if not worktree_path.exists():
|
|
61
|
+
raise ValueError(f"Product worktree not found at {worktree_path}")
|
|
62
|
+
|
|
63
|
+
return workspace_dir, worktree_path, project_id
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Tools
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@tool
|
|
72
|
+
async def promote_to_product(abort: bool = False) -> str:
|
|
73
|
+
"""Merge your product workspace changes into the shared product.
|
|
74
|
+
|
|
75
|
+
Syncs with the latest product state, then merges your changes.
|
|
76
|
+
If there are conflicts, returns both versions for each conflicted file.
|
|
77
|
+
Edit the files in your product workspace to resolve, then call again.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
abort: Set to True to abort an in-progress merge and restore clean state.
|
|
81
|
+
"""
|
|
82
|
+
workspace_dir, worktree_path, project_id = _resolve_product_workspace()
|
|
83
|
+
logger.debug(
|
|
84
|
+
"promote_to_product: project_id={} abort={} ws={} wt={}",
|
|
85
|
+
project_id, abort, workspace_dir, worktree_path,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
result = pw.promote(workspace_dir, worktree_path, project_id, abort=abort)
|
|
89
|
+
status = result.get("status", "")
|
|
90
|
+
message = result.get("message", "")
|
|
91
|
+
conflicts = result.get("conflicts", [])
|
|
92
|
+
|
|
93
|
+
if status == "conflict" and conflicts:
|
|
94
|
+
lines = [f"Merge conflicts detected in {len(conflicts)} file(s):\n"]
|
|
95
|
+
for c in conflicts:
|
|
96
|
+
lines.append(f"--- {c['file']} ---")
|
|
97
|
+
lines.append(f"YOUR VERSION:\n{c['your_version']}")
|
|
98
|
+
lines.append(f"PRODUCT VERSION:\n{c['product_version']}")
|
|
99
|
+
lines.append("")
|
|
100
|
+
lines.append(
|
|
101
|
+
"Edit the conflicted files in your product workspace to resolve, "
|
|
102
|
+
"then call promote_to_product() again."
|
|
103
|
+
)
|
|
104
|
+
return "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
return message
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Export
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
PRODUCT_WORKSPACE_TOOLS = [promote_to_product]
|
|
@@ -113,6 +113,7 @@ def create_product(
|
|
|
113
113
|
"status": status,
|
|
114
114
|
"current_version": current_version,
|
|
115
115
|
"key_results": [],
|
|
116
|
+
"workspace_initialized": False,
|
|
116
117
|
"created_at": now,
|
|
117
118
|
"updated_at": now,
|
|
118
119
|
}
|
|
@@ -645,6 +646,14 @@ def delete_product(slug: str) -> dict:
|
|
|
645
646
|
employee_manager.abort_project(proj["project_id"])
|
|
646
647
|
except Exception as e:
|
|
647
648
|
logger.debug("[PRODUCT] Could not abort project {}: {}", proj["project_id"], e)
|
|
649
|
+
|
|
650
|
+
# Remove product worktree dir if it exists
|
|
651
|
+
from onemancompany.core.config import PRODUCT_WORKTREE_DIR_NAME
|
|
652
|
+
wt_dir = proj_dir / PRODUCT_WORKTREE_DIR_NAME
|
|
653
|
+
if wt_dir.exists():
|
|
654
|
+
_shutil.rmtree(wt_dir)
|
|
655
|
+
logger.debug("[PRODUCT] Removed product worktree for project {}", proj["project_id"])
|
|
656
|
+
|
|
648
657
|
_shutil.rmtree(proj_dir)
|
|
649
658
|
deleted_projects += 1
|
|
650
659
|
logger.debug("[PRODUCT] Deleted linked project {}", proj["project_id"])
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Git operations for product workspaces — init, worktree management, and promote."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from filelock import FileLock
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Helper
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _git(
|
|
20
|
+
args: list[str],
|
|
21
|
+
cwd: Path,
|
|
22
|
+
*,
|
|
23
|
+
check: bool = True,
|
|
24
|
+
) -> subprocess.CompletedProcess[str]:
|
|
25
|
+
"""Run a git command with debug logging.
|
|
26
|
+
|
|
27
|
+
Strips ``GIT_*`` env vars so that tests running inside a git worktree
|
|
28
|
+
(or after other tests that leak git env) don't interfere.
|
|
29
|
+
"""
|
|
30
|
+
cmd = ["git", *args]
|
|
31
|
+
logger.debug("git: {} (cwd={})", " ".join(cmd), cwd)
|
|
32
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
33
|
+
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False, env=env)
|
|
34
|
+
logger.debug("git rc={} stdout={!r} stderr={!r}", result.returncode, result.stdout[:200], result.stderr[:200])
|
|
35
|
+
if check and result.returncode != 0:
|
|
36
|
+
raise subprocess.CalledProcessError(
|
|
37
|
+
result.returncode, cmd, result.stdout, result.stderr
|
|
38
|
+
)
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# init_workspace
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def init_workspace(workspace_dir: Path) -> None:
|
|
48
|
+
"""``git init`` a directory with an initial commit. Idempotent."""
|
|
49
|
+
if (workspace_dir / ".git").is_dir():
|
|
50
|
+
logger.debug("init_workspace: already initialised at {}", workspace_dir)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
workspace_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
_git(["init", "-b", "main"], workspace_dir)
|
|
55
|
+
_git(["config", "user.email", "workspace@localhost"], workspace_dir)
|
|
56
|
+
_git(["config", "user.name", "workspace"], workspace_dir)
|
|
57
|
+
|
|
58
|
+
readme = workspace_dir / "README.md"
|
|
59
|
+
readme.write_text("# Product Workspace\n")
|
|
60
|
+
_git(["add", "README.md"], workspace_dir)
|
|
61
|
+
_git(["commit", "-m", "Initial commit"], workspace_dir)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# add_worktree / remove_worktree
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def add_worktree(workspace_dir: Path, worktree_path: Path, project_id: str) -> None:
|
|
70
|
+
"""``git worktree add`` on branch ``project/{project_id}``. Idempotent."""
|
|
71
|
+
branch = f"project/{project_id}"
|
|
72
|
+
|
|
73
|
+
if worktree_path.is_dir():
|
|
74
|
+
logger.debug("add_worktree: {} already exists, skipping", worktree_path)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
_git(["worktree", "add", "-b", branch, str(worktree_path)], workspace_dir)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def remove_worktree(workspace_dir: Path, worktree_path: Path, project_id: str) -> None:
|
|
81
|
+
"""Remove a worktree and delete its branch. Idempotent / noop if missing."""
|
|
82
|
+
branch = f"project/{project_id}"
|
|
83
|
+
|
|
84
|
+
# Guard: if workspace_dir itself is gone (e.g. product already deleted),
|
|
85
|
+
# there's nothing to clean up in git — just remove the directory.
|
|
86
|
+
if not (workspace_dir / ".git").is_dir():
|
|
87
|
+
logger.debug("remove_worktree: workspace {} gone, skipping git cleanup", workspace_dir)
|
|
88
|
+
if worktree_path.is_dir():
|
|
89
|
+
import shutil
|
|
90
|
+
shutil.rmtree(worktree_path)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if worktree_path.is_dir():
|
|
94
|
+
_git(["worktree", "remove", "--force", str(worktree_path)], workspace_dir)
|
|
95
|
+
|
|
96
|
+
# Prune stale worktree bookkeeping
|
|
97
|
+
_git(["worktree", "prune"], workspace_dir)
|
|
98
|
+
|
|
99
|
+
# Delete branch if it exists
|
|
100
|
+
result = _git(["branch", "--list", branch], workspace_dir)
|
|
101
|
+
if branch in result.stdout:
|
|
102
|
+
_git(["branch", "-D", branch], workspace_dir)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# promote
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_merging(workspace_dir: Path) -> bool:
|
|
111
|
+
"""Check if the workspace is in a merge state."""
|
|
112
|
+
return (workspace_dir / ".git" / "MERGE_HEAD").exists()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _has_conflict_markers(workspace_dir: Path) -> bool:
|
|
116
|
+
"""Check if any unmerged file still has conflict markers in its working-tree content.
|
|
117
|
+
|
|
118
|
+
Two-step check: first find unmerged paths via the index, then read the
|
|
119
|
+
actual file content for ``<<<<<<<`` markers. This avoids false positives
|
|
120
|
+
from ``git diff --check`` (which also flags trailing-whitespace) while
|
|
121
|
+
correctly detecting resolved-but-unstaged files.
|
|
122
|
+
"""
|
|
123
|
+
result = _git(["diff", "--name-only", "--diff-filter=U"], workspace_dir, check=False)
|
|
124
|
+
unmerged = [f for f in result.stdout.strip().splitlines() if f]
|
|
125
|
+
if not unmerged:
|
|
126
|
+
return False
|
|
127
|
+
for fname in unmerged:
|
|
128
|
+
fpath = workspace_dir / fname
|
|
129
|
+
if fpath.is_file() and "<<<<<<<" in fpath.read_text():
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_conflicts(workspace_dir: Path) -> list[dict]:
|
|
135
|
+
"""Parse unmerged files and extract ours/theirs content."""
|
|
136
|
+
result = _git(["diff", "--name-only", "--diff-filter=U"], workspace_dir)
|
|
137
|
+
conflicts = []
|
|
138
|
+
for fname in result.stdout.strip().splitlines():
|
|
139
|
+
if not fname:
|
|
140
|
+
continue
|
|
141
|
+
raw = (workspace_dir / fname).read_text()
|
|
142
|
+
ours = ""
|
|
143
|
+
theirs = ""
|
|
144
|
+
for match in re.finditer(
|
|
145
|
+
r"<<<<<<<[^\n]*\n(.*?)=======\n(.*?)>>>>>>>[^\n]*\n",
|
|
146
|
+
raw,
|
|
147
|
+
re.DOTALL,
|
|
148
|
+
):
|
|
149
|
+
ours += match.group(1)
|
|
150
|
+
theirs += match.group(2)
|
|
151
|
+
conflicts.append({"file": fname, "your_version": theirs, "product_version": ours})
|
|
152
|
+
return conflicts
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _workspace_lock(workspace_dir: Path) -> FileLock:
|
|
156
|
+
"""Return a per-workspace file lock to serialise promote operations."""
|
|
157
|
+
return FileLock(workspace_dir / ".git" / "promote.lock", timeout=120)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def promote(
|
|
161
|
+
workspace_dir: Path,
|
|
162
|
+
worktree_path: Path,
|
|
163
|
+
project_id: str,
|
|
164
|
+
*,
|
|
165
|
+
abort: bool = False,
|
|
166
|
+
) -> dict:
|
|
167
|
+
"""Stateful merge of project branch into main.
|
|
168
|
+
|
|
169
|
+
Acquires a per-workspace file lock so concurrent promote calls on the
|
|
170
|
+
same product are serialised. Returns dict with keys: status, conflicts,
|
|
171
|
+
message.
|
|
172
|
+
"""
|
|
173
|
+
branch = f"project/{project_id}"
|
|
174
|
+
|
|
175
|
+
with _workspace_lock(workspace_dir):
|
|
176
|
+
# --- Abort mode ---
|
|
177
|
+
if abort:
|
|
178
|
+
if _is_merging(workspace_dir):
|
|
179
|
+
_git(["merge", "--abort"], workspace_dir)
|
|
180
|
+
return {"status": "aborted", "conflicts": [], "message": "Merge aborted."}
|
|
181
|
+
return {"status": "aborted", "conflicts": [], "message": "No merge in progress."}
|
|
182
|
+
|
|
183
|
+
# --- Resume after conflict resolution ---
|
|
184
|
+
if _is_merging(workspace_dir):
|
|
185
|
+
if _has_conflict_markers(workspace_dir):
|
|
186
|
+
conflicts = _parse_conflicts(workspace_dir)
|
|
187
|
+
return {
|
|
188
|
+
"status": "conflict",
|
|
189
|
+
"conflicts": conflicts,
|
|
190
|
+
"message": "Conflicts still present.",
|
|
191
|
+
}
|
|
192
|
+
# All resolved — stage and finalize
|
|
193
|
+
_git(["add", "-A"], workspace_dir)
|
|
194
|
+
_git(["commit", "--no-edit"], workspace_dir)
|
|
195
|
+
return {"status": "merged", "conflicts": [], "message": "Merge completed after conflict resolution."}
|
|
196
|
+
|
|
197
|
+
# --- Normal flow: sync main into branch, then merge branch into main ---
|
|
198
|
+
|
|
199
|
+
# Step 1: merge main into project branch (sync — best effort)
|
|
200
|
+
# If the sync conflicts, abort it to avoid leaving the worktree dirty.
|
|
201
|
+
# The promote still works because Step 3 merges the branch HEAD (not
|
|
202
|
+
# the worktree state) into main.
|
|
203
|
+
sync = _git(["merge", "main", "--no-edit"], worktree_path, check=False)
|
|
204
|
+
if sync.returncode != 0:
|
|
205
|
+
_git(["merge", "--abort"], worktree_path, check=False)
|
|
206
|
+
logger.debug("promote: sync merge into branch failed for {}, proceeding", branch)
|
|
207
|
+
|
|
208
|
+
# Step 2: check if branch has anything beyond main
|
|
209
|
+
result = _git(["log", f"main..{branch}", "--oneline"], workspace_dir)
|
|
210
|
+
if not result.stdout.strip():
|
|
211
|
+
return {"status": "nothing", "conflicts": [], "message": "Nothing to merge."}
|
|
212
|
+
|
|
213
|
+
# Step 3: merge project branch into main
|
|
214
|
+
merge = _git(["merge", branch, "--no-edit"], workspace_dir, check=False)
|
|
215
|
+
|
|
216
|
+
if merge.returncode == 0:
|
|
217
|
+
return {"status": "merged", "conflicts": [], "message": "Branch merged into main."}
|
|
218
|
+
|
|
219
|
+
# Conflict
|
|
220
|
+
conflicts = _parse_conflicts(workspace_dir)
|
|
221
|
+
return {
|
|
222
|
+
"status": "conflict",
|
|
223
|
+
"conflicts": conflicts,
|
|
224
|
+
"message": "Merge conflicts detected.",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Context injection helpers
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def format_workspace_context(worktree_path: str, product_name: str, file_count: int) -> str:
|
|
234
|
+
"""Build the context string injected into task descriptions."""
|
|
235
|
+
return (
|
|
236
|
+
f'[Product "{product_name}" workspace: {worktree_path} ({file_count} files)\n'
|
|
237
|
+
f" Read and write files here using your normal tools.\n"
|
|
238
|
+
f" When changes are ready, call promote_to_product() to merge into the product.]"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def count_worktree_files(worktree_path: Path) -> int:
|
|
243
|
+
"""Count user-facing files in a worktree (excluding .git, README.md)."""
|
|
244
|
+
count = 0
|
|
245
|
+
for f in worktree_path.rglob("*"):
|
|
246
|
+
if f.is_file() and ".git" not in f.parts and f.name != "README.md":
|
|
247
|
+
count += 1
|
|
248
|
+
return count
|
|
@@ -24,6 +24,8 @@ import yaml
|
|
|
24
24
|
|
|
25
25
|
from onemancompany.core.config import (
|
|
26
26
|
NODES_DIR_NAME,
|
|
27
|
+
PRODUCT_WORKTREE_DIR_NAME,
|
|
28
|
+
PRODUCTS_DIR,
|
|
27
29
|
PROJECT_YAML_FILENAME,
|
|
28
30
|
PROJECTS_DIR,
|
|
29
31
|
TASK_TREE_FILENAME,
|
|
@@ -325,6 +327,46 @@ def create_project_from_task(task: str, routed_to: str = "pending",
|
|
|
325
327
|
return project_id, iter_id
|
|
326
328
|
|
|
327
329
|
|
|
330
|
+
def _setup_product_worktree(project_id: str, product_id: str) -> None:
|
|
331
|
+
"""Create/reuse a product workspace and add a worktree for this project."""
|
|
332
|
+
from onemancompany.core.product import find_slug_by_product_id, load_product, update_product
|
|
333
|
+
from onemancompany.core.product_workspace import init_workspace, add_worktree
|
|
334
|
+
|
|
335
|
+
product_slug = find_slug_by_product_id(product_id)
|
|
336
|
+
if not product_slug:
|
|
337
|
+
logger.warning("[PROJECT] product_id={} not found, skipping worktree setup", product_id)
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
product = load_product(product_slug)
|
|
341
|
+
workspace_dir = PRODUCTS_DIR / product_slug / "workspace"
|
|
342
|
+
|
|
343
|
+
if not product.get("workspace_initialized"):
|
|
344
|
+
init_workspace(workspace_dir)
|
|
345
|
+
update_product(product_slug, workspace_initialized=True)
|
|
346
|
+
|
|
347
|
+
worktree_path = PROJECTS_DIR / project_id / PRODUCT_WORKTREE_DIR_NAME
|
|
348
|
+
add_worktree(workspace_dir, worktree_path, project_id)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _cleanup_product_worktree(project_id: str, proj_doc: dict) -> None:
|
|
352
|
+
"""Remove the product worktree for an archived project."""
|
|
353
|
+
product_id = proj_doc.get("product_id", "")
|
|
354
|
+
if not product_id:
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
from onemancompany.core.product import find_slug_by_product_id
|
|
358
|
+
from onemancompany.core.product_workspace import remove_worktree
|
|
359
|
+
|
|
360
|
+
product_slug = find_slug_by_product_id(product_id)
|
|
361
|
+
if not product_slug:
|
|
362
|
+
logger.debug("[PROJECT] product_id={} not found during worktree cleanup", product_id)
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
workspace_dir = PRODUCTS_DIR / product_slug / "workspace"
|
|
366
|
+
worktree_path = PROJECTS_DIR / project_id / PRODUCT_WORKTREE_DIR_NAME
|
|
367
|
+
remove_worktree(workspace_dir, worktree_path, project_id)
|
|
368
|
+
|
|
369
|
+
|
|
328
370
|
def create_named_project(name: str, *, product_id: str = "") -> str:
|
|
329
371
|
"""Create a persistent named project. Returns the project_id (UUID-based)."""
|
|
330
372
|
slug = uuid.uuid4().hex[:12]
|
|
@@ -352,6 +394,10 @@ def create_named_project(name: str, *, product_id: str = "") -> str:
|
|
|
352
394
|
lock = _get_project_lock(slug)
|
|
353
395
|
with lock, open_utf(path, "w") as f:
|
|
354
396
|
yaml.dump(doc, f, allow_unicode=True, default_flow_style=False)
|
|
397
|
+
|
|
398
|
+
if product_id:
|
|
399
|
+
_setup_product_worktree(slug, product_id)
|
|
400
|
+
|
|
355
401
|
return slug
|
|
356
402
|
|
|
357
403
|
|
|
@@ -513,6 +559,8 @@ def archive_project(project_id: str) -> None:
|
|
|
513
559
|
with lock, open_utf(path, "w") as f:
|
|
514
560
|
yaml.dump(proj, f, allow_unicode=True, default_flow_style=False)
|
|
515
561
|
|
|
562
|
+
_cleanup_product_worktree(project_id, proj)
|
|
563
|
+
|
|
516
564
|
# Close project conversation so it disappears from CEO console
|
|
517
565
|
try:
|
|
518
566
|
from onemancompany.core.conversation import get_conversation_service
|
|
@@ -1459,6 +1459,12 @@ class EmployeeManager:
|
|
|
1459
1459
|
if _effective_dir:
|
|
1460
1460
|
task_with_ctx += f"\n\n[Project workspace: {_effective_dir} — save all outputs here]"
|
|
1461
1461
|
|
|
1462
|
+
# Product workspace — inject if project has a product worktree
|
|
1463
|
+
if project_id:
|
|
1464
|
+
_pw_ctx = self._get_product_workspace_context(project_id)
|
|
1465
|
+
if _pw_ctx:
|
|
1466
|
+
task_with_ctx += f"\n\n{_pw_ctx}"
|
|
1467
|
+
|
|
1462
1468
|
if project_id:
|
|
1463
1469
|
proj_ctx = self._get_project_history_context(project_id)
|
|
1464
1470
|
if proj_ctx:
|
|
@@ -2065,6 +2071,37 @@ class EmployeeManager:
|
|
|
2065
2071
|
_CTX_MAX_WORKSPACE_FILES = 30
|
|
2066
2072
|
_CTX_MAX_CRITERIA = 5
|
|
2067
2073
|
|
|
2074
|
+
@staticmethod
|
|
2075
|
+
def _get_product_workspace_context(project_id: str) -> str:
|
|
2076
|
+
"""Build product workspace context string if project is linked to a product."""
|
|
2077
|
+
from onemancompany.core.project_archive import load_named_project
|
|
2078
|
+
from onemancompany.core.product import find_slug_by_product_id, load_product
|
|
2079
|
+
from onemancompany.core.config import PRODUCTS_DIR, PROJECTS_DIR, PRODUCT_WORKTREE_DIR_NAME
|
|
2080
|
+
|
|
2081
|
+
base_project_id = project_id.split("/")[0]
|
|
2082
|
+
proj_doc = load_named_project(base_project_id)
|
|
2083
|
+
if not proj_doc:
|
|
2084
|
+
return ""
|
|
2085
|
+
product_id = proj_doc.get("product_id", "")
|
|
2086
|
+
if not product_id:
|
|
2087
|
+
return ""
|
|
2088
|
+
|
|
2089
|
+
slug = find_slug_by_product_id(product_id)
|
|
2090
|
+
if not slug:
|
|
2091
|
+
return ""
|
|
2092
|
+
|
|
2093
|
+
product = load_product(slug)
|
|
2094
|
+
if not product or not product.get("workspace_initialized", False):
|
|
2095
|
+
return ""
|
|
2096
|
+
|
|
2097
|
+
worktree_path = PROJECTS_DIR / base_project_id / PRODUCT_WORKTREE_DIR_NAME
|
|
2098
|
+
if not worktree_path.is_dir():
|
|
2099
|
+
return ""
|
|
2100
|
+
|
|
2101
|
+
from onemancompany.core.product_workspace import format_workspace_context, count_worktree_files
|
|
2102
|
+
file_count = count_worktree_files(worktree_path)
|
|
2103
|
+
return format_workspace_context(str(worktree_path), product.get("name", slug), file_count)
|
|
2104
|
+
|
|
2068
2105
|
def _get_project_history_context(self, project_id: str) -> str:
|
|
2069
2106
|
from onemancompany.core.project_archive import (
|
|
2070
2107
|
_is_iteration, _find_project_for_iteration,
|