@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.12",
3
+ "version": "0.7.19",
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.19"
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]
@@ -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,