@1mancompany/onemancompany 0.7.12 → 0.7.20

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/frontend/app.js CHANGED
@@ -795,6 +795,9 @@ class AppController {
795
795
  // Create Product modal
796
796
  this._initCreateProductModal();
797
797
 
798
+ // Product selector feedback
799
+ this._initProductSelector();
800
+
798
801
  hrBtn?.addEventListener('click', () => {
799
802
  hrBtn.disabled = true;
800
803
  this.logEntry('CEO', '🔄 Triggering quarterly review...', 'ceo');
@@ -7262,6 +7265,20 @@ class AppController {
7262
7265
  }
7263
7266
 
7264
7267
  // ===== Product Selector =====
7268
+ _initProductSelector() {
7269
+ const sel = document.getElementById('ceo-product-select');
7270
+ if (!sel) return;
7271
+ sel.addEventListener('change', () => {
7272
+ if (sel.value) {
7273
+ sel.style.borderColor = 'var(--pixel-cyan)';
7274
+ sel.style.color = 'var(--pixel-white)';
7275
+ } else {
7276
+ sel.style.borderColor = '';
7277
+ sel.style.color = '';
7278
+ }
7279
+ });
7280
+ }
7281
+
7265
7282
  async _refreshProductSelector() {
7266
7283
  try {
7267
7284
  const data = await fetch('/api/products').then(r => r.json());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.12",
3
+ "version": "0.7.20",
4
4
  "description": "The AI Operating System for One-Person Companies",
5
5
  "bin": {
6
6
  "onemancompany": "bin/cli.js"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "onemancompany"
3
- version = "0.7.12"
3
+ version = "0.7.20"
4
4
  description = "A one-man company simulation with pixel art visualization and LangChain AI agents"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -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]
@@ -655,7 +655,7 @@ async def ceo_submit_task(
655
655
 
656
656
  @router.post("/api/task/{project_id}/followup")
657
657
  async def task_followup(project_id: str, body: dict) -> dict:
658
- """CEO adds follow-up instructions to an existing task, dispatched to EA with context."""
658
+ """CEO adds follow-up instructions to an existing task, dispatched to assignee (product owner or EA) with context."""
659
659
  from datetime import datetime as _dt
660
660
 
661
661
  from onemancompany.core.agent_loop import get_agent_loop
@@ -699,7 +699,23 @@ async def task_followup(project_id: str, body: dict) -> dict:
699
699
  for fname in deliverables:
700
700
  work_summary_lines.append(f" {fname}")
701
701
 
702
- # Build follow-up task for EA
702
+ # Determine assignee: product owner if product-linked, else EA
703
+ assignee_id = EA_ID
704
+ product_id = doc.get("product_id", "")
705
+ if product_id:
706
+ from onemancompany.core.product import find_slug_by_product_id, load_product
707
+ product_slug = find_slug_by_product_id(product_id)
708
+ if product_slug:
709
+ product = load_product(product_slug)
710
+ if product and product.get("owner_id"):
711
+ assignee_id = product["owner_id"]
712
+ logger.debug("[FOLLOWUP] Product-linked project {}, routing to owner {}",
713
+ project_id, assignee_id)
714
+ if assignee_id == EA_ID:
715
+ logger.debug("[FOLLOWUP] No product owner found for project {}, falling back to EA",
716
+ project_id)
717
+
718
+ # Build follow-up task for assignee
703
719
  context_parts = [
704
720
  f"CEO has added follow-up instructions to a completed task:\n",
705
721
  f"Original task: {original_task}\n",
@@ -721,11 +737,11 @@ async def task_followup(project_id: str, body: dict) -> dict:
721
737
  else:
722
738
  tree = TaskTree(project_id=project_id)
723
739
 
724
- ea_loop = get_agent_loop(EA_ID)
725
- if not ea_loop:
726
- raise HTTPException(status_code=503, detail="EA agent not available")
740
+ assignee_loop = get_agent_loop(assignee_id)
741
+ if not assignee_loop:
742
+ raise HTTPException(status_code=503, detail=f"Agent {assignee_id} not available")
727
743
 
728
- schedule_node_id = "" # will be set to the EA node to schedule
744
+ schedule_node_id = "" # will be set to the assignee node to schedule
729
745
 
730
746
  if tree.root_id:
731
747
  # Add a new subtree from CEO root — old subtree stays intact
@@ -741,39 +757,39 @@ async def task_followup(project_id: str, body: dict) -> dict:
741
757
  followup_node.node_type = NodeType.CEO_FOLLOWUP
742
758
  followup_node.status = TaskPhase.ACCEPTED.value
743
759
 
744
- # Create a new EA node under the followup node for execution
745
- ea_child = tree.add_child(
760
+ # Create execution node under the followup node
761
+ exec_child = tree.add_child(
746
762
  parent_id=followup_node.id,
747
- employee_id=EA_ID,
763
+ employee_id=assignee_id,
748
764
  description=followup_task,
749
765
  acceptance_criteria=[],
750
766
  )
751
- schedule_node_id = ea_child.id
767
+ schedule_node_id = exec_child.id
752
768
 
753
769
  # Keep CEO root in PROCESSING while new subtree runs
754
770
  if root and root.node_type == NodeType.CEO_PROMPT:
755
771
  root.status = TaskPhase.PROCESSING.value
756
772
  else:
757
- # No root yet — create CEO root + EA child
773
+ # No root yet — create CEO root + assignee child
758
774
  ceo_root = tree.create_root(employee_id=CEO_ID, description=instructions)
759
775
  ceo_root.node_type = NodeType.CEO_PROMPT
760
776
  ceo_root.set_status(TaskPhase.PROCESSING)
761
- ea_child = tree.add_child(
777
+ exec_child = tree.add_child(
762
778
  parent_id=ceo_root.id,
763
- employee_id=EA_ID,
779
+ employee_id=assignee_id,
764
780
  description=instructions,
765
781
  acceptance_criteria=[],
766
782
  )
767
- schedule_node_id = ea_child.id
783
+ schedule_node_id = exec_child.id
768
784
 
769
785
  _save_project_tree(pdir, tree)
770
786
 
771
- # Schedule the EA node for execution
787
+ # Schedule the assignee node for execution
772
788
  if schedule_node_id:
773
789
  tree_path = str(Path(pdir) / TASK_TREE_FILENAME)
774
790
  from onemancompany.core.agent_loop import employee_manager
775
- employee_manager.schedule_node(EA_ID, schedule_node_id, tree_path)
776
- employee_manager._schedule_next(EA_ID)
791
+ employee_manager.schedule_node(assignee_id, schedule_node_id, tree_path)
792
+ employee_manager._schedule_next(assignee_id)
777
793
 
778
794
  # Update project.yaml status back to in_progress
779
795
  doc["status"] = "in_progress"
@@ -69,6 +69,7 @@ SRC_DIR_NAME = "src"
69
69
  LAUNCH_SH_FILENAME = "launch.sh"
70
70
  PROMPTS_DIR_NAME = "prompts"
71
71
  WORKSPACE_DIR_NAME = "workspace"
72
+ PRODUCT_WORKTREE_DIR_NAME = "product_worktree"
72
73
  VESSEL_DIR_NAME = "vessel"
73
74
  AGENT_DIR_NAME = "agent"
74
75
 
@@ -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,