@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 +17 -0
- 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/api/routes.py +33 -17
- 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/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
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]
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
725
|
-
if not
|
|
726
|
-
raise HTTPException(status_code=503, detail="
|
|
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
|
|
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
|
|
745
|
-
|
|
760
|
+
# Create execution node under the followup node
|
|
761
|
+
exec_child = tree.add_child(
|
|
746
762
|
parent_id=followup_node.id,
|
|
747
|
-
employee_id=
|
|
763
|
+
employee_id=assignee_id,
|
|
748
764
|
description=followup_task,
|
|
749
765
|
acceptance_criteria=[],
|
|
750
766
|
)
|
|
751
|
-
schedule_node_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 +
|
|
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
|
-
|
|
777
|
+
exec_child = tree.add_child(
|
|
762
778
|
parent_id=ceo_root.id,
|
|
763
|
-
employee_id=
|
|
779
|
+
employee_id=assignee_id,
|
|
764
780
|
description=instructions,
|
|
765
781
|
acceptance_criteria=[],
|
|
766
782
|
)
|
|
767
|
-
schedule_node_id =
|
|
783
|
+
schedule_node_id = exec_child.id
|
|
768
784
|
|
|
769
785
|
_save_project_tree(pdir, tree)
|
|
770
786
|
|
|
771
|
-
# Schedule the
|
|
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(
|
|
776
|
-
employee_manager._schedule_next(
|
|
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"
|
|
@@ -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,
|