fuli-context 0.7.10-beta.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +5 -2
  2. package/README.zh-CN.md +5 -3
  3. package/dist/web/assets/{AboutPage-BvXH4hsO.js → AboutPage-BjMmpwJB.js} +1 -1
  4. package/dist/web/assets/AboutPage-vYIBifKw.css +1 -0
  5. package/dist/web/assets/AgentHand-BfLYPGjg.js +1 -0
  6. package/dist/web/assets/AgentHand-D9sQu2vc.css +1 -0
  7. package/dist/web/assets/ConnectionsPage-BKGxFDos.js +1 -0
  8. package/dist/web/assets/EmployeeRecruitDialog-B59X3rOi.css +1 -0
  9. package/dist/web/assets/EmployeeRecruitDialog-D-W043v4.js +1 -0
  10. package/dist/web/assets/EmployeeWorkbenchPage-Cz7y-IFD.css +1 -0
  11. package/dist/web/assets/EmployeeWorkbenchPage-HHQPxdqI.js +3 -0
  12. package/dist/web/assets/GrowthLoading-B_Hh0CRS.css +1 -0
  13. package/dist/web/assets/GrowthLoading-WXCj8b0r.js +1 -0
  14. package/dist/web/assets/KnowledgeEditDialog-Bkie08ZA.js +1 -0
  15. package/dist/web/assets/KnowledgeEditDialog-D9NuRHvp.css +1 -0
  16. package/dist/web/assets/KnowledgeInspector-Cez-Z_95.js +1 -0
  17. package/dist/web/assets/KnowledgeOrganizerPage-BYmTPA_8.js +1 -0
  18. package/dist/web/assets/KnowledgeOrganizerPage-h2iKvQpO.css +1 -0
  19. package/dist/web/assets/KnowledgePage-Ck7tIBDn.js +1 -0
  20. package/dist/web/assets/KnowledgeWorkspace-DKij2_B4.js +4 -0
  21. package/dist/web/assets/KnowledgeWorkspace-sb8OJ97I.css +1 -0
  22. package/dist/web/assets/NotFoundPage-HGV-q-bh.js +1 -0
  23. package/dist/web/assets/{OverviewPage-D4vLhIFD.js → OverviewPage-DE29_F_Y.js} +1 -1
  24. package/dist/web/assets/PersonalProfilePage-B95DFc7x.js +1 -0
  25. package/dist/web/assets/PersonalProfilePage-L8T5xMEC.css +1 -0
  26. package/dist/web/assets/PersonalProjectsPage-Bt7tFF5Z.js +1 -0
  27. package/dist/web/assets/ProjectAgentsPage-BioDR9OW.css +1 -0
  28. package/dist/web/assets/ProjectAgentsPage-CiiPNe_G.js +12 -0
  29. package/dist/web/assets/ProjectScopePicker-BvaGTMFx.css +1 -0
  30. package/dist/web/assets/ProjectScopePicker-Cu5PdxF6.js +1 -0
  31. package/dist/web/assets/PublicProjectsPage-BY4B3_t3.js +1 -0
  32. package/dist/web/assets/ReviewPage-C9vCesNu.js +1 -0
  33. package/dist/web/assets/{SearchableSelect-DY-TWC2i.js → SearchableSelect-214TvfYg.js} +1 -1
  34. package/dist/web/assets/SettingsPage-Brl-SL33.css +1 -0
  35. package/dist/web/assets/SettingsPage-D9TnzcFc.js +1 -0
  36. package/dist/web/assets/SharedJefaReportPage-CdSGI_OE.js +1 -0
  37. package/dist/web/assets/SharedJefaReportPage-DXbj_JhO.css +1 -0
  38. package/dist/web/assets/WritingTastePage-Bzg6AvnB.css +1 -0
  39. package/dist/web/assets/WritingTastePage-CUE1dU8f.js +1 -0
  40. package/dist/web/assets/bole-avatar-CVIr-bU_.webp +0 -0
  41. package/dist/web/assets/client-Bl4oiW3E.js +1 -0
  42. package/dist/web/assets/console-BAIVNC-N.js +1 -0
  43. package/dist/web/assets/conversation-launcher-settings-DpRI_Jp2.js +1 -0
  44. package/dist/web/assets/employee-dialog-Bh5ccIbK.css +1 -0
  45. package/dist/web/assets/{i18n-C7r_00q2.js → i18n-CVFADb_M.js} +3 -3
  46. package/dist/web/assets/{identity-COvBmUF4.js → identity-Dt0WZgqo.js} +1 -1
  47. package/dist/web/assets/index-BUMq5zNh.js +2 -0
  48. package/dist/web/assets/index-C0ci8dnU.css +1 -0
  49. package/dist/web/assets/{model-CL1JILt4.js → model-B8sOxih0.js} +1 -1
  50. package/dist/web/assets/personal-profile-graph-aAqex7ty.js +1 -0
  51. package/dist/web/assets/shared-report-BQD6l_cS.js +1 -0
  52. package/dist/web/assets/shared-report-ezeb8dW4.css +1 -0
  53. package/dist/web/assets/{useApi-CROJJdhE-DERnOR_k.js → useApi-CROJJdhE-CuJ16v2S.js} +1 -1
  54. package/dist/web/assets/useModalDialog-Ddv2XJQi.js +1 -0
  55. package/dist/web/assets/{vue-router-DJ6C6-8v.js → vue-router-C8KuEZ6a.js} +1 -1
  56. package/dist/web/index.html +14 -8
  57. package/docs/agent-interface-architecture.md +97 -0
  58. package/docs/employee-agents.md +58 -2
  59. package/docs/project-agent-memory.md +69 -449
  60. package/graph-provider/fuli_graph/app.py +2 -0
  61. package/graph-provider/fuli_graph/collaboration_context.py +2 -1
  62. package/graph-provider/fuli_graph/graph_models.py +5 -3
  63. package/graph-provider/fuli_graph/graph_query.py +4 -0
  64. package/graph-provider/fuli_graph/knowledge_search.py +2 -1
  65. package/graph-provider/fuli_graph/preference_conflicts.py +4 -1
  66. package/graph-provider/fuli_graph/project_agent_access.py +2 -0
  67. package/graph-provider/fuli_graph/project_agent_attention_models.py +64 -0
  68. package/graph-provider/fuli_graph/project_agent_attention_routes.py +38 -0
  69. package/graph-provider/fuli_graph/project_agent_coordination_models.py +23 -1
  70. package/graph-provider/fuli_graph/project_agent_memory_models.py +11 -0
  71. package/graph-provider/fuli_graph/project_agent_models.py +2 -0
  72. package/graph-provider/fuli_graph/project_agent_names.py +33 -0
  73. package/graph-provider/fuli_graph/project_agent_routes.py +12 -0
  74. package/graph-provider/fuli_graph/project_agent_team_growth.py +56 -0
  75. package/graph-provider/fuli_graph/project_agent_work_log.py +34 -0
  76. package/graph-provider/fuli_graph/runtime.py +49 -20
  77. package/graph-provider/fuli_graph/store.py +2 -0
  78. package/graph-provider/fuli_graph/store_knowledge.py +4 -2
  79. package/graph-provider/fuli_graph/store_project_agent_attention.py +138 -0
  80. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +56 -2
  81. package/graph-provider/fuli_graph/store_project_agent_executor_learning.py +8 -0
  82. package/graph-provider/fuli_graph/store_project_agent_memory.py +43 -13
  83. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +1 -1
  84. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +6 -1
  85. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +21 -3
  86. package/graph-provider/fuli_graph/store_project_agent_tasks.py +4 -0
  87. package/graph-provider/fuli_graph/store_project_agents.py +109 -0
  88. package/graph-provider/fuli_graph/store_task_contexts.py +12 -2
  89. package/graph-provider/fuli_graph/system_hr_identity.py +218 -0
  90. package/graph-provider/fuli_graph/task_context_adoption.py +65 -0
  91. package/graph-provider/fuli_graph/task_context_models.py +23 -1
  92. package/graph-provider/fuli_graph/task_context_routes.py +5 -1
  93. package/graph-provider/pyproject.toml +1 -1
  94. package/npm-shrinkwrap.json +52 -60
  95. package/package.json +2 -2
  96. package/skills/operating-fuli/SKILL.md +47 -0
  97. package/skills/operating-fuli/agents/openai.yaml +4 -0
  98. package/skills/operating-fuli/references/workflow-recipes.md +33 -0
  99. package/src/agent-tools/agent-attention-definitions.js +33 -0
  100. package/src/agent-tools/graph-definitions.js +22 -7
  101. package/src/agent-tools/graph-handlers.js +41 -0
  102. package/src/agent-tools/interface-catalog.js +101 -0
  103. package/src/agent-tools/interface-definitions.js +184 -0
  104. package/src/agent-tools/project-agent-definitions.js +2 -0
  105. package/src/agents/codex/lifecycle-hook.js +1 -0
  106. package/src/cli/local-runtime-command.js +2 -1
  107. package/src/cli/setup-command.js +1 -1
  108. package/src/employees/catalog/bole.json +19 -0
  109. package/src/employees/install-package.js +3 -0
  110. package/src/employees/manifest.js +4 -0
  111. package/src/employees/package-registry.js +2 -0
  112. package/src/employees/recruitment.js +3 -0
  113. package/src/employees/service.js +10 -0
  114. package/src/external-knowledge/registry.js +24 -0
  115. package/src/external-knowledge/service.js +34 -1
  116. package/src/graphiti/agent-attention.js +26 -0
  117. package/src/graphiti/agent-knowledge-workflows.js +14 -4
  118. package/src/graphiti/project-agent-control-plane.js +30 -0
  119. package/src/graphiti/project-agent-mapping.js +18 -1
  120. package/src/graphiti/project-agent-memory.js +10 -2
  121. package/src/graphiti/project-agent-task-entry.js +4 -1
  122. package/src/graphiti/project-agent-team-context.js +21 -0
  123. package/src/graphiti/project-agent-workflows.js +18 -0
  124. package/src/graphiti/provider-client.js +20 -1
  125. package/src/graphiti/workspace-provider-client.js +9 -1
  126. package/src/graphiti/writing-taste-profile-workflow.js +62 -9
  127. package/src/graphiti/writing-taste-profile.js +43 -16
  128. package/src/http/agent-attention-api-router.js +28 -0
  129. package/src/http/error-mapping.js +3 -0
  130. package/src/http/graph-api-router.js +11 -0
  131. package/src/local-runtime/lifecycle.js +8 -2
  132. package/src/mcp/create-mcp-server.js +15 -4
  133. package/src/mcp/instructions.js +3 -3
  134. package/src/mcp/provider-task-context-registry.js +11 -1
  135. package/src/mcp/task-context-registry.js +2 -1
  136. package/src/mcp/tool-annotations.js +31 -0
  137. package/src/mcp/tool-result.js +11 -7
  138. package/src/setup/agent-installation-status.js +2 -1
  139. package/src/setup/agents.js +4 -0
  140. package/src/setup/graph-runtime.js +4 -0
  141. package/src/setup/paths.js +2 -1
  142. package/src/setup/setup.js +4 -2
  143. package/src/setup/uninstall.js +4 -2
  144. package/src/system/resource-monitor.js +11 -1
  145. package/src/system/runtime-settings.js +7 -0
  146. package/dist/web/assets/AboutPage-DXh7Fwxt.css +0 -1
  147. package/dist/web/assets/ConnectionsPage-WVc6Srbd.js +0 -1
  148. package/dist/web/assets/EmployeeRecruitDialog-CnA7rOnB.css +0 -1
  149. package/dist/web/assets/EmployeeRecruitDialog-Dal5wy8a.js +0 -1
  150. package/dist/web/assets/EmployeeWorkbenchPage-BDJvrpcn.css +0 -1
  151. package/dist/web/assets/EmployeeWorkbenchPage-CW5pu_Yb.js +0 -1
  152. package/dist/web/assets/KnowledgeEditDialog-BBvm74ln.js +0 -1
  153. package/dist/web/assets/KnowledgeEditDialog-iv-dgUeD.css +0 -1
  154. package/dist/web/assets/KnowledgeInspector-CE9ADmtT.js +0 -1
  155. package/dist/web/assets/KnowledgeOrganizerPage-BRtizGUS.js +0 -1
  156. package/dist/web/assets/KnowledgeOrganizerPage-Cx1w1jb2.css +0 -1
  157. package/dist/web/assets/KnowledgePage-D5clXGCw.js +0 -1
  158. package/dist/web/assets/KnowledgeWorkspace-CKLH6cIf.js +0 -4
  159. package/dist/web/assets/PersonalProfilePage-7w8rAzkg.js +0 -1
  160. package/dist/web/assets/PersonalProfilePage-B7T09bow.css +0 -1
  161. package/dist/web/assets/PersonalProjectsPage-BY0B1t24.js +0 -1
  162. package/dist/web/assets/ProjectAgentsPage-C3vW6wmk.css +0 -1
  163. package/dist/web/assets/ProjectAgentsPage-LHPOuLWw.js +0 -11
  164. package/dist/web/assets/PublicProjectsPage-PSp6RzVZ.js +0 -1
  165. package/dist/web/assets/ReviewPage-DPP23xLF.js +0 -1
  166. package/dist/web/assets/SettingsPage-C59OPsNP.js +0 -1
  167. package/dist/web/assets/SettingsPage-CwZp2ka4.css +0 -1
  168. package/dist/web/assets/WritingTastePage-DNXhCdgn.js +0 -1
  169. package/dist/web/assets/WritingTastePage-DSgvTH6x.css +0 -1
  170. package/dist/web/assets/avatars-BfZueqlm.js +0 -1
  171. package/dist/web/assets/console-CYXvhGCR.js +0 -1
  172. package/dist/web/assets/conversation-launcher-settings-SwmUP91W.js +0 -1
  173. package/dist/web/assets/index-B-rXoqYP.js +0 -2
  174. package/dist/web/assets/index-Ch1jmkyO.css +0 -1
@@ -0,0 +1,138 @@
1
+ import hashlib
2
+ import json
3
+
4
+ from fastapi import HTTPException
5
+
6
+ from .personal_project_access import authorize_personal_project
7
+ from .project_agent_access import authorize_project_agent
8
+ from .project_agent_attention_models import (
9
+ AgentAttentionCreate, AgentAttentionDecision, AgentAttentionList,
10
+ AgentAttentionRecord,
11
+ )
12
+ from .provider_values import native_datetime, now_utc, stable_uuid
13
+ from .system_hr_identity import resolve_hr_alias
14
+
15
+
16
+ class StoreProjectAgentAttention:
17
+ """Explicit human requests; never infer attention from worker/task status."""
18
+
19
+ async def create_agent_attention(self, actor, request: AgentAttentionCreate):
20
+ self._require_personal()
21
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
22
+ agent_id = await resolve_hr_alias(self, request.personal_space_id, request.agent_id)
23
+ await authorize_project_agent(
24
+ self, actor, space, request.personal_project_id, agent_id,
25
+ require_active=True,
26
+ allow_unassigned=agent_id in {
27
+ 'employee.bole', 'employee.jefa', 'fuli-project-coordinator',
28
+ },
29
+ )
30
+ if request.task_id:
31
+ task = await self._find_task_row(
32
+ request.personal_space_id, request.task_id,
33
+ request.personal_project_id,
34
+ )
35
+ if not task:
36
+ raise HTTPException(status_code=404, detail='project Agent task not found')
37
+ payload = request.model_dump(exclude={'idempotency_key'})
38
+ fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
39
+ payload['agent_id'] = agent_id
40
+ request_id = stable_uuid(
41
+ self.settings.provider_id, request.personal_space_id,
42
+ request.personal_project_id, 'agent-attention', request.idempotency_key,
43
+ )
44
+ timestamp = now_utc()
45
+ rows, _, _ = await self.runtime.driver.execute_query(
46
+ '''
47
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
48
+ MERGE (request:FuliAgentAttention {id: $request_id})
49
+ ON CREATE SET request += $payload,
50
+ request.request_id = $request_id,
51
+ request.fingerprint = $fingerprint,
52
+ request.status = 'open', request.revision = 0,
53
+ request.created_at = $timestamp,
54
+ request.updated_at = $timestamp
55
+ MERGE (space)-[:HAS_AGENT_ATTENTION]->(request)
56
+ RETURN request
57
+ ''',
58
+ personal_space_id=request.personal_space_id, request_id=request_id,
59
+ payload=payload, fingerprint=fingerprint, timestamp=timestamp,
60
+ )
61
+ raw = dict(rows[0]['request'])
62
+ if raw['fingerprint'] != fingerprint:
63
+ raise HTTPException(status_code=409, detail='attention idempotency key conflict')
64
+ return attention_record(raw)
65
+
66
+ async def list_agent_attention(
67
+ self, actor, personal_space_id, personal_project_id=None,
68
+ agent_id=None, status='open', limit=100, offset=0,
69
+ ):
70
+ self._require_personal()
71
+ space = await self.authorize(actor, personal_space_id, 'reader')
72
+ agent_id = await resolve_hr_alias(self, personal_space_id, agent_id)
73
+ if personal_project_id:
74
+ await authorize_personal_project(self, actor, space, personal_project_id)
75
+ rows, _, _ = await self.runtime.driver.execute_query(
76
+ '''
77
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
78
+ [:HAS_AGENT_ATTENTION]->(request:FuliAgentAttention)
79
+ WHERE ($project_id IS NULL OR request.personal_project_id = $project_id)
80
+ AND ($agent_id IS NULL OR request.agent_id = $agent_id)
81
+ AND ($status IS NULL OR request.status = $status)
82
+ WITH request ORDER BY request.created_at DESC, request.request_id
83
+ WITH collect(request) AS requests
84
+ RETURN requests[$offset..($offset + $limit)] AS items,
85
+ size(requests) AS total,
86
+ [r IN requests WHERE r.status = 'open' | r.agent_id] AS open_agents
87
+ ''', space_id=personal_space_id, project_id=personal_project_id,
88
+ agent_id=agent_id, status=status, limit=limit, offset=offset, routing_='r',
89
+ )
90
+ row = rows[0] if rows else {'items': [], 'total': 0, 'open_agents': []}
91
+ counts = {}
92
+ for agent in row['open_agents']:
93
+ counts[agent] = counts.get(agent, 0) + 1
94
+ return AgentAttentionList(
95
+ items=[attention_record(dict(item)) for item in row['items']],
96
+ total=row['total'], counts=counts,
97
+ )
98
+
99
+ async def decide_agent_attention(
100
+ self, actor, request: AgentAttentionDecision, *, human: bool,
101
+ ):
102
+ self._require_personal()
103
+ space = await self.authorize(actor, request.personal_space_id, 'maintainer')
104
+ await authorize_personal_project(self, actor, space, request.personal_project_id)
105
+ timestamp = now_utc()
106
+ # Increment obtains a write lock before the revision predicate is checked.
107
+ # A stale caller cannot overwrite the response recorded by another client.
108
+ rows, _, _ = await self.runtime.driver.execute_query(
109
+ '''
110
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
111
+ [:HAS_AGENT_ATTENTION]->(request:FuliAgentAttention {
112
+ request_id: $request_id, personal_project_id: $project_id
113
+ })
114
+ SET request.lock_version = coalesce(request.lock_version, 0) + 1
115
+ WITH request
116
+ WHERE request.revision = $revision AND request.status = 'open'
117
+ SET request.status = $status, request.response = $response,
118
+ request.responded_by = $responded_by,
119
+ request.revision = request.revision + 1,
120
+ request.updated_at = $timestamp
121
+ RETURN request
122
+ ''', space_id=request.personal_space_id,
123
+ project_id=request.personal_project_id, request_id=request.request_id,
124
+ revision=request.expected_revision, response=request.response,
125
+ status='resolved' if human else 'cancelled',
126
+ responded_by='human' if human else 'agent', timestamp=timestamp,
127
+ )
128
+ if not rows:
129
+ raise HTTPException(status_code=409, detail='attention changed or is no longer open; reload it')
130
+ return attention_record(dict(rows[0]['request']))
131
+
132
+
133
+ def attention_record(raw):
134
+ return AgentAttentionRecord(**{
135
+ key: native_datetime(raw.get(key)) if key in {'created_at', 'updated_at'} else raw.get(key)
136
+ for key in AgentAttentionRecord.model_fields
137
+ if key in raw
138
+ })
@@ -1,5 +1,7 @@
1
1
  """Project-local Agent continuity and recruitment authorization."""
2
2
 
3
+ from fastapi import HTTPException
4
+
3
5
  from .personal_project_access import authorize_personal_project
4
6
  from .project_agent_coordination_models import (
5
7
  ProjectAgentCoordinationPolicyRecord,
@@ -9,7 +11,7 @@ from .provider_values import native_datetime, now_utc, stable_uuid
9
11
 
10
12
 
11
13
  class StoreProjectAgentCoordinationPolicy:
12
- """Persist the two user-facing project Agent automation switches."""
14
+ """Persist project automation and a stable team without absorbing peer roles."""
13
15
 
14
16
  async def get_project_agent_coordination_policy(
15
17
  self,
@@ -53,6 +55,9 @@ class StoreProjectAgentCoordinationPolicy:
53
55
  'auto_reuse_previous_agent',
54
56
  True,
55
57
  ),
58
+ team_lead_agent_id=raw.get('team_lead_agent_id'),
59
+ auto_grow_team=raw.get('auto_grow_team', True),
60
+ team_member_agent_ids=raw.get('team_member_agent_ids') or [],
56
61
  updated_at=native_datetime(raw.get('updated_at')),
57
62
  )
58
63
 
@@ -77,8 +82,38 @@ class StoreProjectAgentCoordinationPolicy:
77
82
  request.personal_space_id,
78
83
  request.personal_project_id,
79
84
  )
85
+ current = await self.get_project_agent_coordination_policy(
86
+ actor, request.personal_space_id, request.personal_project_id,
87
+ )
88
+ if ('expected_updated_at' in request.model_fields_set
89
+ and request.expected_updated_at != current.updated_at):
90
+ raise HTTPException(status_code=409, detail='coordination policy changed; reload before saving')
91
+ lead = (request.team_lead_agent_id if 'team_lead_agent_id' in request.model_fields_set
92
+ else current.team_lead_agent_id)
93
+ members = (request.team_member_agent_ids if 'team_member_agent_ids' in request.model_fields_set
94
+ else current.team_member_agent_ids)
95
+ auto_grow = current.auto_grow_team if request.auto_grow_team is None else request.auto_grow_team
96
+ if members and not lead:
97
+ raise HTTPException(status_code=422, detail='select a team lead before adding members')
98
+ if lead in members:
99
+ raise HTTPException(status_code=422, detail='the team lead cannot also be a team member')
100
+ if 'team_lead_agent_id' in request.model_fields_set or 'team_member_agent_ids' in request.model_fields_set:
101
+ for agent_id in ([lead] if lead else []) + members:
102
+ agent = await self.get_project_agent(
103
+ actor, request.personal_space_id, request.personal_project_id, agent_id,
104
+ )
105
+ profile = agent.profile
106
+ active_assignment = any(
107
+ assignment.status == 'active'
108
+ and assignment.personal_project_id == request.personal_project_id
109
+ for assignment in agent.assignments
110
+ )
111
+ if (profile.status != 'active' or not active_assignment
112
+ or profile.agent_type != 'durable'
113
+ or any(cap.startswith('fuli.employee:') for cap in profile.capabilities)):
114
+ raise HTTPException(status_code=422, detail='team roles require active durable project assignments; HR and project managers remain peers')
80
115
  updated_at = now_utc()
81
- await self.runtime.driver.execute_query(
116
+ written, _, _ = await self.runtime.driver.execute_query(
82
117
  '''
83
118
  MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
84
119
  [:CONTAINS_PROJECT]->
@@ -89,23 +124,42 @@ class StoreProjectAgentCoordinationPolicy:
89
124
  ON CREATE SET policy.personal_space_id = $personal_space_id,
90
125
  policy.personal_project_id = $personal_project_id,
91
126
  policy.created_at = $updated_at
127
+ SET policy._fuli_write_lock = true
128
+ REMOVE policy._fuli_write_lock
129
+ WITH project, policy
130
+ WHERE ($expected_updated_at IS NULL AND policy.updated_at IS NULL)
131
+ OR (policy.updated_at.epochSeconds = $expected_updated_at.epochSeconds
132
+ AND policy.updated_at.nanosecond = $expected_updated_at.nanosecond)
92
133
  SET policy.ask_before_recruitment = $ask_before_recruitment,
93
134
  policy.auto_reuse_previous_agent = $auto_reuse_previous_agent,
135
+ policy.auto_grow_team = $auto_grow_team,
136
+ policy.team_lead_agent_id = $team_lead_agent_id,
137
+ policy.team_member_agent_ids = $team_member_agent_ids,
94
138
  policy.updated_at = $updated_at
95
139
  MERGE (project)-[:HAS_PROJECT_AGENT_COORDINATION_POLICY]->(policy)
140
+ RETURN policy
96
141
  ''',
97
142
  policy_id=policy_id,
98
143
  personal_space_id=request.personal_space_id,
99
144
  personal_project_id=request.personal_project_id,
100
145
  ask_before_recruitment=request.ask_before_recruitment,
101
146
  auto_reuse_previous_agent=request.auto_reuse_previous_agent,
147
+ auto_grow_team=auto_grow,
148
+ team_lead_agent_id=lead,
149
+ team_member_agent_ids=members,
150
+ expected_updated_at=current.updated_at,
102
151
  updated_at=updated_at,
103
152
  )
153
+ if not written:
154
+ raise HTTPException(status_code=409, detail='coordination policy changed; reload before saving')
104
155
  return ProjectAgentCoordinationPolicyRecord(
105
156
  personal_space_id=request.personal_space_id,
106
157
  personal_project_id=request.personal_project_id,
107
158
  ask_before_recruitment=request.ask_before_recruitment,
108
159
  auto_reuse_previous_agent=request.auto_reuse_previous_agent,
160
+ auto_grow_team=auto_grow,
161
+ team_lead_agent_id=lead,
162
+ team_member_agent_ids=members,
109
163
  updated_at=updated_at,
110
164
  )
111
165
 
@@ -22,6 +22,7 @@ from .project_agent_executor_models import (
22
22
  from .project_agent_models import ProjectAgentModelStrategy
23
23
  from .provider_values import native_datetime, now_utc, stable_uuid
24
24
  from .store_transactions import query_store_transaction
25
+ from .system_hr_identity import resolve_hr_alias
25
26
 
26
27
 
27
28
  _TERMINAL_NEUTRAL = {'cancelled'}
@@ -276,6 +277,7 @@ class StoreProjectAgentExecutorLearning:
276
277
  ) -> list[ProjectAgentExecutorOutcomeEvidenceRecord]:
277
278
  self._require_personal()
278
279
  space = await self.authorize(actor, personal_space_id, 'reader')
280
+ agent_id = await resolve_hr_alias(self, personal_space_id, agent_id)
279
281
  await authorize_personal_project(
280
282
  self,
281
283
  actor,
@@ -334,6 +336,7 @@ class StoreProjectAgentExecutorLearning:
334
336
 
335
337
  self._require_personal()
336
338
  space = await self.authorize(actor, personal_space_id, 'reader')
339
+ agent_id = await resolve_hr_alias(self, personal_space_id, agent_id)
337
340
  if personal_project_id is not None:
338
341
  await authorize_personal_project(
339
342
  self,
@@ -367,12 +370,17 @@ class StoreProjectAgentExecutorLearning:
367
370
  aggregates: list[ProjectAgentExecutorOutcomeAggregate] = []
368
371
  for row in records:
369
372
  raw = dict(row.get('aggregate') or {})
373
+ identity = raw.get('agent_id')
370
374
  aggregate_json = raw.get('aggregate_json')
371
375
  if aggregate_json:
372
376
  try:
373
377
  raw.update(json.loads(aggregate_json))
374
378
  except (TypeError, ValueError):
375
379
  pass
380
+ if raw.get('hr_identity_original_refs_json'):
381
+ raw['agent_id'] = identity
382
+ raw.pop('hr_identity_original_refs_json', None)
383
+ raw.pop('hr_identity_original_id', None)
376
384
  # Storage identities are intentionally internal: the public model
377
385
  # remains the provider-neutral bucket contract.
378
386
  raw.pop('id', None)
@@ -16,6 +16,8 @@ from .project_agent_memory_models import (
16
16
  ProjectAgentMemoryWrite,
17
17
  )
18
18
  from .provider_values import now_utc, stable_uuid
19
+ from .project_agent_work_log import read_employee_work_log
20
+ from .system_hr_identity import LEGACY_HR_AGENT_ID, SYSTEM_HR_AGENT_ID, resolve_hr_alias
19
21
 
20
22
 
21
23
  class StoreProjectAgentMemory:
@@ -24,7 +26,7 @@ class StoreProjectAgentMemory:
24
26
  ) -> ProjectAgentMemoryView:
25
27
  self._require_personal()
26
28
  space = await self.authorize(actor, personal_space_id, 'reader')
27
- await authorize_project_agent(
29
+ agent = await authorize_project_agent(
28
30
  self, actor, space, personal_project_id, agent_id, require_memory=True,
29
31
  )
30
32
  records, _, _ = await self.runtime.driver.execute_query(
@@ -34,24 +36,26 @@ class StoreProjectAgentMemory:
34
36
  RETURN checkpoint.record_json AS record_json
35
37
  ORDER BY checkpoint.revision DESC LIMIT $limit
36
38
  ''',
37
- memory_id=self._agent_memory_id(
38
- personal_space_id, personal_project_id, agent_id,
39
- ),
39
+ memory_id=await self._resolved_agent_memory_id(
40
+ personal_space_id, personal_project_id, agent['agent_id']),
40
41
  limit=max(1, min(limit, 10)),
41
42
  routing_='r',
42
43
  )
43
44
  history = [
44
- ProjectAgentMemoryRecord.model_validate_json(row['record_json'])
45
+ ProjectAgentMemoryRecord.model_validate_json(row['record_json']).model_copy(
46
+ update={'agent_id': agent['agent_id']})
45
47
  for row in records
46
48
  ]
47
49
  current = history[0] if history else None
48
50
  return ProjectAgentMemoryView(
49
51
  personal_space_id=personal_space_id,
50
52
  personal_project_id=personal_project_id,
51
- agent_id=agent_id,
53
+ agent_id=agent['agent_id'],
52
54
  revision=current.revision if current else 0,
53
55
  current=current,
54
56
  history=history,
57
+ work_log=await read_employee_work_log(self, personal_space_id,
58
+ personal_project_id, agent['agent_id']),
55
59
  )
56
60
 
57
61
  async def write_project_agent_memory(
@@ -78,14 +82,14 @@ class StoreProjectAgentMemory:
78
82
  ''',
79
83
  space_id=request.personal_space_id,
80
84
  project_id=request.personal_project_id,
81
- agent_id=request.agent_id,
85
+ agent_id=agent['agent_id'],
82
86
  task_id=request.task_id,
83
87
  routing_='r',
84
88
  )
85
89
  if not task_rows:
86
90
  raise HTTPException(status_code=404, detail='Agent memory task not found')
87
- memory_id = self._agent_memory_id(
88
- request.personal_space_id, request.personal_project_id, request.agent_id,
91
+ memory_id = await self._resolved_agent_memory_id(
92
+ request.personal_space_id, request.personal_project_id, agent['agent_id'],
89
93
  )
90
94
  checkpoint_id = stable_uuid(memory_id, request.idempotency_key)
91
95
  payload = request.model_dump(mode='json', exclude={
@@ -98,7 +102,7 @@ class StoreProjectAgentMemory:
98
102
  checkpoint_id=checkpoint_id,
99
103
  personal_space_id=request.personal_space_id,
100
104
  personal_project_id=request.personal_project_id,
101
- agent_id=request.agent_id,
105
+ agent_id=agent['agent_id'],
102
106
  revision=request.expected_revision + 1,
103
107
  memory=request.memory,
104
108
  source_application=request.source_application,
@@ -147,7 +151,7 @@ class StoreProjectAgentMemory:
147
151
  ''',
148
152
  space_id=request.personal_space_id,
149
153
  project_id=request.personal_project_id,
150
- agent_id=request.agent_id,
154
+ agent_id=agent['agent_id'],
151
155
  memory_id=memory_id,
152
156
  checkpoint_id=checkpoint_id,
153
157
  expected_revision=request.expected_revision,
@@ -164,9 +168,35 @@ class StoreProjectAgentMemory:
164
168
  status_code=409,
165
169
  detail='Agent memory changed; reload its latest revision before merging notes',
166
170
  )
167
- if row['payload_hash'] != payload_hash:
171
+ compatible_hashes = {payload_hash}
172
+ if LEGACY_HR_AGENT_ID in agent.get('legacy_agent_ids', []):
173
+ # Old checkpoints keep their exact IDs/JSON/hashes. A canonical
174
+ # retry of the same input may differ only in the identity spelling.
175
+ for identity in (LEGACY_HR_AGENT_ID, SYSTEM_HR_AGENT_ID):
176
+ compatible_hashes.add(hashlib.sha256(json.dumps(
177
+ {**payload, 'agent_id': identity}, sort_keys=True,
178
+ ensure_ascii=False, separators=(',', ':'),
179
+ ).encode()).hexdigest())
180
+ if row['payload_hash'] not in compatible_hashes:
168
181
  raise HTTPException(status_code=409, detail='memory idempotency key has different input')
169
- return ProjectAgentMemoryRecord.model_validate_json(row['record_json'])
182
+ return ProjectAgentMemoryRecord.model_validate_json(row['record_json']).model_copy(
183
+ update={'agent_id': agent['agent_id']})
184
+
185
+ async def _resolved_agent_memory_id(self, space_id, project_id, agent_id):
186
+ agent_id = await resolve_hr_alias(self, space_id, agent_id)
187
+ if agent_id == SYSTEM_HR_AGENT_ID:
188
+ rows, _, _ = await self.runtime.driver.execute_query(
189
+ '''MATCH (:FuliSpace {id: $space_id})-[:HAS_PROJECT_AGENT_IDENTITY]->
190
+ (:FuliProjectAgent {agent_id: $agent_id})-[:HAS_WORKING_MEMORY]->
191
+ (head:FuliProjectAgentMemory {personal_space_id: $space_id,
192
+ personal_project_id: $project_id})
193
+ RETURN DISTINCT head.id AS id''',
194
+ space_id=space_id, project_id=project_id, agent_id=agent_id, routing_='r')
195
+ if len(rows) > 1:
196
+ raise HTTPException(409, 'Agent has conflicting memory heads')
197
+ if rows:
198
+ return rows[0]['id']
199
+ return self._agent_memory_id(space_id, project_id, agent_id)
170
200
 
171
201
  def _agent_memory_id(self, space_id, project_id, agent_id):
172
202
  return stable_uuid(
@@ -266,7 +266,7 @@ class StoreProjectAgentTaskActivity:
266
266
  THEN [1] ELSE [] END |
267
267
  SET task.status = $status,
268
268
  task.result_summary = CASE
269
- WHEN $status = 'completed' THEN $summary
269
+ WHEN $status IN ['completed', 'awaiting_review', 'failed', 'blocked', 'cancelled'] THEN $summary
270
270
  ELSE task.result_summary END,
271
271
  task.failure_reason = CASE
272
272
  WHEN $status = 'failed' THEN $summary
@@ -24,7 +24,9 @@ from .project_agent_task_models import (
24
24
  ProjectAgentRoutingDecisionRecord,
25
25
  )
26
26
  from .provider_values import native_datetime, now_utc, stable_uuid
27
+ from .store_project_agents import SYSTEM_HR_AGENT_ID
27
28
  from .store_transactions import query_store_transaction
29
+ from .project_agent_team_growth import enroll_recruited_team_member
28
30
 
29
31
 
30
32
  RECRUITMENT_CLAIM_TTL = timedelta(minutes=2)
@@ -744,9 +746,11 @@ class StoreProjectAgentTaskRecruitment:
744
746
  MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
745
747
  [:HAS_PROJECT_AGENT_IDENTITY]->
746
748
  (hr:FuliProjectAgent {agent_type: 'hr', status: 'active'})
747
- RETURN hr ORDER BY hr.created_at, hr.agent_id LIMIT 1
749
+ RETURN hr ORDER BY CASE WHEN hr.agent_id = $system_hr_agent_id
750
+ THEN 0 ELSE 1 END, hr.created_at, hr.agent_id LIMIT 1
748
751
  ''',
749
752
  personal_space_id=request.personal_space_id,
753
+ system_hr_agent_id=SYSTEM_HR_AGENT_ID,
750
754
  routing_='r',
751
755
  )
752
756
  hr_agent_id = (
@@ -1233,6 +1237,7 @@ class StoreProjectAgentTaskRecruitment:
1233
1237
  include_temporary=True,
1234
1238
  )
1235
1239
  selected = next(item for item in assignment_rows if item['agent_id'] == agent_id)
1240
+ await enroll_recruited_team_member(self, raw, profile)
1236
1241
  return selected
1237
1242
 
1238
1243
  async def _get_recruitment(self, personal_space_id, recruitment_id):
@@ -7,6 +7,7 @@ from fastapi import HTTPException
7
7
  from .project_agent_access import authorize_project_agent
8
8
  from .project_agent_models import ProjectAgentProfile
9
9
  from .project_agent_task_models import ProjectAgentParallelPlan
10
+ from .store_project_agents import SYSTEM_HR_AGENT_ID
10
11
 
11
12
 
12
13
  class StoreProjectAgentTaskStaffing:
@@ -61,6 +62,18 @@ class StoreProjectAgentTaskStaffing:
61
62
  return [], rows, 'manual_agent_selection', [
62
63
  'project policy requires an explicit @Agent selection',
63
64
  ]
65
+ team_lead = getattr(policy, 'team_lead_agent_id', None)
66
+ if team_lead:
67
+ lead = next((row for row in rows if row['agent_id'] == team_lead), None)
68
+ if lead is None:
69
+ return [], rows, 'agent_unavailable', ['configured team lead is unavailable; select or replace the lead']
70
+ member_ids = set(getattr(policy, 'team_member_agent_ids', []))
71
+ members = [row for row in rows if row['agent_id'] in member_ids]
72
+ return [lead], [lead, *members], 'project_team_lead', [
73
+ 'project team lead is the stable conversation owner',
74
+ f'{len(members)} active team member(s) available for explicit workstreams',
75
+ 'HR and project manager remain peer roles',
76
+ ]
64
77
  normalized_work_kind = request.work_kind.casefold()
65
78
  required = {item.casefold() for item in request.required_capabilities}
66
79
  eligible = [row for row in rows if required.issubset({
@@ -505,16 +518,19 @@ class StoreProjectAgentTaskStaffing:
505
518
  request,
506
519
  candidates,
507
520
  participants,
521
+ *,
522
+ team_only=False,
508
523
  ):
509
524
  capacity = len(request.parallel_plan.workstream_boundaries) - len(participants)
510
525
  if capacity <= 0:
511
526
  return []
512
527
  pool = list(candidates)
513
528
  known_ids = {item['agent_id'] for item in pool}
514
- for row in await self._assignment_candidates(
529
+ assignments = [] if team_only else await self._assignment_candidates(
515
530
  request.personal_space_id,
516
531
  request.personal_project_id,
517
- ):
532
+ )
533
+ for row in assignments:
518
534
  if row['agent_id'] not in known_ids:
519
535
  pool.append(row)
520
536
  known_ids.add(row['agent_id'])
@@ -634,9 +650,11 @@ class StoreProjectAgentTaskStaffing:
634
650
  MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
635
651
  [:HAS_PROJECT_AGENT_IDENTITY]->
636
652
  (hr:FuliProjectAgent {agent_type: 'hr', status: 'active'})
637
- RETURN hr LIMIT 1
653
+ RETURN hr ORDER BY CASE WHEN hr.agent_id = $system_hr_agent_id
654
+ THEN 0 ELSE 1 END, hr.created_at, hr.agent_id LIMIT 1
638
655
  ''',
639
656
  personal_space_id=request.personal_space_id,
657
+ system_hr_agent_id=SYSTEM_HR_AGENT_ID,
640
658
  routing_='r',
641
659
  )
642
660
  if not hr_records:
@@ -2,6 +2,7 @@ import json
2
2
  from collections import defaultdict
3
3
  from datetime import date
4
4
  from fastapi import HTTPException
5
+ from .system_hr_identity import resolve_hr_alias
5
6
  from .personal_project_access import authorize_personal_project
6
7
  from .project_agent_access import authorize_project_agent
7
8
  from .project_agent_models import ProjectAgentModelStrategy, ProjectAgentProfile
@@ -146,6 +147,7 @@ class StoreProjectAgentTasks(
146
147
  request,
147
148
  candidates,
148
149
  participants,
150
+ **({'team_only': True} if reason == 'project_team_lead' else {}),
149
151
  )
150
152
  candidate_ids = {item['agent_id'] for item in candidates}
151
153
  candidates.extend(
@@ -470,6 +472,7 @@ class StoreProjectAgentTasks(
470
472
  ) -> list[ProjectAgentTaskRecord]:
471
473
  self._require_personal()
472
474
  space = await self.authorize(actor, personal_space_id, 'reader')
475
+ agent_id = await resolve_hr_alias(self, personal_space_id, agent_id)
473
476
  if personal_project_id:
474
477
  await authorize_personal_project(
475
478
  self,
@@ -510,6 +513,7 @@ class StoreProjectAgentTasks(
510
513
  ) -> ProjectAgentActivityResult:
511
514
  self._require_personal()
512
515
  space = await self.authorize(actor, personal_space_id, 'reader')
516
+ agent_id = await resolve_hr_alias(self, personal_space_id, agent_id)
513
517
  if personal_project_id:
514
518
  await authorize_personal_project(
515
519
  self,