fuli-context 0.11.0 → 0.12.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 (169) hide show
  1. package/dist/web/assets/AboutPage-BAD0iPVX.js +1 -0
  2. package/dist/web/assets/AgentConversations-1iGTd4sz.js +2 -0
  3. package/dist/web/assets/AgentHand-D_RafKOM.js +1 -0
  4. package/dist/web/assets/AgentName-B5LKQ09q.js +1 -0
  5. package/dist/web/assets/AgentProfilePage-CioOdkBM.js +1 -0
  6. package/dist/web/assets/AgentsDirectoryPage--1A9nvNA.js +1 -0
  7. package/dist/web/assets/AgentsDirectoryPage-C7OUQH9v.css +1 -0
  8. package/dist/web/assets/ConnectionsPage-Tr2rRVOZ.js +1 -0
  9. package/dist/web/assets/EmployeeRecruitDialog-1QevD4Qv.js +1 -0
  10. package/dist/web/assets/EmployeeWorkbenchPage-B0vQIgwA.js +3 -0
  11. package/dist/web/assets/EmployeeWorkbenchPage-BQes4L1f.css +1 -0
  12. package/dist/web/assets/KnowledgeEditDialog-ClnrfPZi.js +1 -0
  13. package/dist/web/assets/KnowledgeInspector-Te1hJZbx.js +1 -0
  14. package/dist/web/assets/KnowledgeOrganizerPage-CCCrWl6Y.js +1 -0
  15. package/dist/web/assets/KnowledgeOrganizerPage-ccgscrPd.css +1 -0
  16. package/dist/web/assets/KnowledgePage-DGOcBwr8.js +1 -0
  17. package/dist/web/assets/KnowledgeWorkspace-cKn1NCRe.js +4 -0
  18. package/dist/web/assets/NotFoundPage-fetSnp0N.js +1 -0
  19. package/dist/web/assets/OverviewPage-BD4yY2Xz.js +1 -0
  20. package/dist/web/assets/PersonalProfilePage-DGJVP_VB.js +1 -0
  21. package/dist/web/assets/PersonalProjectsPage-D25HYMgD.js +1 -0
  22. package/dist/web/assets/ProjectAgentFirstTask-BcxqmKXQ.js +1 -0
  23. package/dist/web/assets/ProjectAgentsPage-BWDJBESb.js +12 -0
  24. package/dist/web/assets/{ProjectAgentsPage-DNLNjYp-.css → ProjectAgentsPage-DWGkgIsr.css} +1 -1
  25. package/dist/web/assets/ProjectScopePicker-yodM7lF_.js +1 -0
  26. package/dist/web/assets/PublicProjectsPage-CyNxVzW3.js +1 -0
  27. package/dist/web/assets/ReviewPage-Cy0Rt4CO.js +1 -0
  28. package/dist/web/assets/RoundtablesPage-BjVhvwMc.js +1 -0
  29. package/dist/web/assets/SettingsPage-CCZqZtnH.js +1 -0
  30. package/dist/web/assets/SettingsPage-DYz5bTwa.css +1 -0
  31. package/dist/web/assets/SharedJefaReportPage-C56kPciB.js +1 -0
  32. package/dist/web/assets/UiDialog-CvDxg3B1.js +1 -0
  33. package/dist/web/assets/UiDisclosure-D0iMTplF.js +1 -0
  34. package/dist/web/assets/UiSegmented-DIJW6wv3.js +1 -0
  35. package/dist/web/assets/UiSelect-CMR0QqF6.js +1 -0
  36. package/dist/web/assets/WritingTastePage-QgNzssfF.js +1 -0
  37. package/dist/web/assets/agent-pins-BcJ_qr8n.js +1 -0
  38. package/dist/web/assets/agent-work-summary-C-yW7x7A.js +1 -0
  39. package/dist/web/assets/{attention-store-BfxtL9EV.js → attention-store-B1DI8wmd.js} +1 -1
  40. package/dist/web/assets/{catalog-6SN9i3WR.js → catalog-DbXv0_bR.js} +1 -1
  41. package/dist/web/assets/client-CR-W5Wzp.js +1 -0
  42. package/dist/web/assets/console-D7GHOMEC.js +1 -0
  43. package/dist/web/assets/conversation-launcher-settings-Dj2GMzCI.js +1 -0
  44. package/dist/web/assets/{i18n-CCMR4gDS.js → i18n-CVFhT_k7.js} +3 -3
  45. package/dist/web/assets/{identity-C1eN-FSB.js → identity-CM6Dmy00.js} +1 -1
  46. package/dist/web/assets/index-CJ6Jmok9.css +1 -0
  47. package/dist/web/assets/index-CRa3GnYi.js +5 -0
  48. package/dist/web/assets/judgment-types-CJBEyJs1.js +1 -0
  49. package/dist/web/assets/model-WFhQZ3v_.js +1 -0
  50. package/dist/web/assets/{personal-profile-graph-BD1Y6TDS.js → personal-profile-graph-BwCh2M9G.js} +1 -1
  51. package/dist/web/assets/shared-report-CeIvrL9a.js +1 -0
  52. package/dist/web/assets/{agent-work-summary-LeW-Al_S.js → task-evidence-DTiPCs7r.js} +1 -1
  53. package/dist/web/assets/useAgentRoster-DCArHlxK.js +1 -0
  54. package/dist/web/assets/{useApi-CROJJdhE-DlOiu8lP.js → useApi-CROJJdhE-BYhbjaZB.js} +1 -1
  55. package/dist/web/assets/vue-router-DaxrQwX3.js +1 -0
  56. package/dist/web/index.html +16 -12
  57. package/docs/agent-roundtable.md +17 -1
  58. package/docs/employee-agents.md +1 -1
  59. package/docs/judgment-and-autonomy.md +33 -0
  60. package/graph-provider/fuli_graph/ai_review.py +154 -0
  61. package/graph-provider/fuli_graph/common_knowledge.py +12 -0
  62. package/graph-provider/fuli_graph/graph_models.py +14 -0
  63. package/graph-provider/fuli_graph/graph_query.py +217 -0
  64. package/graph-provider/fuli_graph/knowledge_audit.py +6 -0
  65. package/graph-provider/fuli_graph/knowledge_batch_confirmation.py +6 -0
  66. package/graph-provider/fuli_graph/knowledge_management.py +23 -0
  67. package/graph-provider/fuli_graph/knowledge_review.py +383 -4
  68. package/graph-provider/fuli_graph/knowledge_review_models.py +61 -2
  69. package/graph-provider/fuli_graph/personal_global_preferences.py +5 -1
  70. package/graph-provider/fuli_graph/preference_conflicts.py +91 -1
  71. package/graph-provider/fuli_graph/project_agent_context_models.py +5 -1
  72. package/graph-provider/fuli_graph/project_agent_delegations.py +118 -0
  73. package/graph-provider/fuli_graph/project_agent_employee_numbers.py +41 -0
  74. package/graph-provider/fuli_graph/project_agent_memory_routes.py +13 -0
  75. package/graph-provider/fuli_graph/project_agent_models.py +1 -0
  76. package/graph-provider/fuli_graph/project_agent_task_models.py +2 -0
  77. package/graph-provider/fuli_graph/project_agent_team_growth.py +2 -2
  78. package/graph-provider/fuli_graph/project_knowledge.py +41 -0
  79. package/graph-provider/fuli_graph/runtime.py +15 -0
  80. package/graph-provider/fuli_graph/store_agent_verification.py +12 -5
  81. package/graph-provider/fuli_graph/store_knowledge.py +98 -58
  82. package/graph-provider/fuli_graph/store_project_agent_attention.py +1 -1
  83. package/graph-provider/fuli_graph/store_project_agent_context.py +52 -4
  84. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +16 -1
  85. package/graph-provider/fuli_graph/store_project_agent_task_activity.py +26 -0
  86. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +5 -3
  87. package/graph-provider/fuli_graph/store_project_agents.py +31 -5
  88. package/graph-provider/fuli_graph/system_agents.py +23 -2
  89. package/graph-provider/fuli_graph/system_hr_identity.py +19 -5
  90. package/graph-provider/pyproject.toml +1 -1
  91. package/npm-shrinkwrap.json +2 -2
  92. package/package.json +3 -2
  93. package/src/agent-roundtable/service.js +34 -5
  94. package/src/agent-roundtable/wake.js +2 -1
  95. package/src/agent-tools/graph-definitions.js +2 -0
  96. package/src/agent-tools/graph-handlers.js +7 -0
  97. package/src/agent-tools/interface-catalog.js +6 -0
  98. package/src/agent-tools/judgment-definitions.js +12 -0
  99. package/src/agents/claude-code/wake.js +7 -0
  100. package/src/agents/codex/wake.js +7 -0
  101. package/src/agents/identity-receipt.js +20 -2
  102. package/src/employees/catalog/tonborg.json +20 -0
  103. package/src/employees/manifest.js +2 -2
  104. package/src/employees/recruitment.js +20 -1
  105. package/src/graphiti/agent-collaboration.js +8 -2
  106. package/src/graphiti/federated-application.js +11 -2
  107. package/src/graphiti/knowledge-review.js +7 -3
  108. package/src/graphiti/project-agent-mapping.js +4 -0
  109. package/src/graphiti/project-agent-task-entry.js +23 -5
  110. package/src/graphiti/project-agent-team-context.js +4 -1
  111. package/src/graphiti/provider-client.js +9 -0
  112. package/src/graphiti/task-entry-preferences.js +17 -4
  113. package/src/graphiti/task-entry-project-scope.js +67 -0
  114. package/src/http/api-router.js +2 -0
  115. package/src/judgment/acceptance.js +91 -0
  116. package/src/judgment/engine.js +168 -0
  117. package/src/judgment/http.js +24 -0
  118. package/src/judgment/model.js +24 -0
  119. package/src/judgment/routing.js +96 -0
  120. package/src/judgment/scheduler.js +25 -0
  121. package/src/judgment/service.js +66 -0
  122. package/src/judgment/store.js +123 -0
  123. package/src/mcp/session-id.js +1 -0
  124. package/src/mcp/tool-annotations.js +4 -2
  125. package/src/server.js +9 -2
  126. package/dist/web/assets/AboutPage-CKq4IRHd.js +0 -1
  127. package/dist/web/assets/AgentConversations-DfXiFvBN.js +0 -2
  128. package/dist/web/assets/AgentHand-BBM-sKh2.js +0 -1
  129. package/dist/web/assets/AgentName-C_6R80nP.js +0 -1
  130. package/dist/web/assets/AgentProfilePage-Cz-i1fRD.js +0 -1
  131. package/dist/web/assets/AgentsDirectoryPage-BI0EFz6y.js +0 -1
  132. package/dist/web/assets/AgentsDirectoryPage-yRWktiin.css +0 -1
  133. package/dist/web/assets/ConnectionsPage-BoC6kXgs.js +0 -1
  134. package/dist/web/assets/EmployeeRecruitDialog-BVyclQAJ.js +0 -1
  135. package/dist/web/assets/EmployeeWorkbenchPage-D-c_Jm2z.css +0 -1
  136. package/dist/web/assets/EmployeeWorkbenchPage-D2GvYraI.js +0 -3
  137. package/dist/web/assets/KnowledgeEditDialog-a0AKWics.js +0 -1
  138. package/dist/web/assets/KnowledgeInspector-DMg9yJZ2.js +0 -1
  139. package/dist/web/assets/KnowledgeOrganizerPage-BgsHM3Qw.js +0 -1
  140. package/dist/web/assets/KnowledgeOrganizerPage-DeL7Jp34.css +0 -1
  141. package/dist/web/assets/KnowledgePage-kn8MqUCO.js +0 -1
  142. package/dist/web/assets/KnowledgeWorkspace-BtRq9fvC.js +0 -4
  143. package/dist/web/assets/NotFoundPage-D-7jLz3o.js +0 -1
  144. package/dist/web/assets/OverviewPage-BHPc-XxY.js +0 -1
  145. package/dist/web/assets/PersonalProfilePage-C00iV0Kx.js +0 -1
  146. package/dist/web/assets/PersonalProjectsPage-CFNkdHSo.js +0 -1
  147. package/dist/web/assets/ProjectAgentFirstTask-CrCWEYgn.js +0 -1
  148. package/dist/web/assets/ProjectAgentsPage-BzztLMH-.js +0 -12
  149. package/dist/web/assets/ProjectScopePicker-CMxn3txv.js +0 -1
  150. package/dist/web/assets/PublicProjectsPage-DQVtvhOS.js +0 -1
  151. package/dist/web/assets/ReviewPage-BZhvyDWc.js +0 -1
  152. package/dist/web/assets/RoundtablesPage-CVebTXQa.js +0 -1
  153. package/dist/web/assets/SettingsPage-1knvfOW6.js +0 -1
  154. package/dist/web/assets/SettingsPage-BmYkLul4.css +0 -1
  155. package/dist/web/assets/SharedJefaReportPage-BCVq8u0P.js +0 -1
  156. package/dist/web/assets/UiDialog-Da1P10f4.js +0 -1
  157. package/dist/web/assets/UiDisclosure-8wE8YuE4.js +0 -1
  158. package/dist/web/assets/UiSegmented-BKZXc8QF.js +0 -1
  159. package/dist/web/assets/UiSelect-D5Xj5Ec4.js +0 -1
  160. package/dist/web/assets/WritingTastePage-B8-etOBn.js +0 -1
  161. package/dist/web/assets/client-CUTkF3t4.js +0 -1
  162. package/dist/web/assets/console-CG8tdtSs.js +0 -1
  163. package/dist/web/assets/conversation-launcher-settings-CyZkLJg7.js +0 -1
  164. package/dist/web/assets/index-kPB0Cf8Q.css +0 -1
  165. package/dist/web/assets/index-sLDKI6R-.js +0 -5
  166. package/dist/web/assets/model-l-sAqtW_.js +0 -1
  167. package/dist/web/assets/shared-report-B46dxlCR.js +0 -1
  168. package/dist/web/assets/useAgentRoster-DdxoKDEE.js +0 -1
  169. package/dist/web/assets/vue-router-DjqEU4W2.js +0 -1
@@ -43,7 +43,7 @@ class StoreAgentVerification:
43
43
  task = await self._quality_task(actor, request)
44
44
  rows, _, _ = await self.runtime.driver.execute_query(
45
45
  'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space})-[:HAS_VERIFICATION]->(v:FuliAgentVerification) '
46
- 'RETURN v.record_json AS record_json ORDER BY v.sequence', task=request.task_id, space=request.personal_space_id)
46
+ 'RETURN v.record_json AS record_json, v.run_id AS run_id ORDER BY v.sequence', task=request.task_id, space=request.personal_space_id)
47
47
  models = await self._quality_models(actor, request, task)
48
48
  result = evaluate_quality_policy(complexity=task.get('complexity') or 'standard',
49
49
  current_strategy=json.loads(task.get('effective_model_strategy_json') or '{"mode":"adaptive"}'),
@@ -53,7 +53,12 @@ class StoreAgentVerification:
53
53
  if selected:
54
54
  selected['executor_id'] = next(model['executor_id'] for model in models
55
55
  if all(model.get(key) == selected[key] for key in ('provider', 'model', 'capability_tier', 'cost_rank')))
56
- return {**result, 'evidence_authority': 'host_reported_test_evidence',
56
+ verified = next((row for row in rows if json.loads(row['record_json']).get('attempt_id') == result['verified_attempt_id']), None)
57
+ attempt = json.loads(verified['record_json']) if verified else None
58
+ if attempt:
59
+ attempt = {key: attempt.get(key) for key in ('attempt_id', 'artifact_revision', 'outcome', 'evidence_refs', 'verifier_id', 'executor_id', 'provider', 'model')}
60
+ attempt['run_id'] = verified.get('run_id')
61
+ return {**result, 'verified_attempt': attempt, 'evidence_authority': 'host_reported_test_evidence',
57
62
  'execution_started': False, 'guidance': 'Only complete the task after passed for the current artifact revision. Blocked means no authorized preflighted model proves the required capability tier. Model selection does not prove execution.'}
58
63
 
59
64
  async def record_agent_verification(self, actor, request):
@@ -92,7 +97,8 @@ class StoreAgentVerification:
92
97
  raise HTTPException(409, 'An execution run cannot count as two verification attempts')
93
98
  identifier = stable_uuid('verification', request.personal_space_id, request.task_id, request.attempt_id)
94
99
  record = dict(attempt_id=request.attempt_id, outcome=request.outcome, artifact_revision=request.artifact_revision,
95
- evidence_refs=request.evidence_refs, capability_tier=observed['capability_tier'], verifier_id=context['project_agent_id'])
100
+ evidence_refs=request.evidence_refs, capability_tier=observed['capability_tier'], verifier_id=context['project_agent_id'],
101
+ executor_id=request.executor_id, provider=request.provider, model=request.model)
96
102
  fingerprint = store._payload_hash(request)
97
103
  rows, _, _ = await store.runtime.driver.execute_query(
98
104
  'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space}) '
@@ -106,6 +112,7 @@ class StoreAgentVerification:
106
112
  result = await store.query_agent_quality(actor, request)
107
113
  await store.runtime.driver.execute_query(
108
114
  'MATCH (t:FuliProjectAgentTask {task_id:$task, personal_space_id:$space}) '
109
- 'SET t.quality_gate=$status, t.verified_artifact_revision=$revision',
110
- task=request.task_id, space=request.personal_space_id, status=result['status'], revision=request.artifact_revision)
115
+ 'SET t.quality_gate=$status, t.verified_artifact_revision=$revision, t.verified_attempt_id=$attempt',
116
+ task=request.task_id, space=request.personal_space_id, status=result['status'], revision=request.artifact_revision,
117
+ attempt=result.get('verified_attempt_id'))
111
118
  return result
@@ -325,6 +325,8 @@ class StoreKnowledge:
325
325
  await self.runtime.driver.execute_query(
326
326
  '''
327
327
  UNWIND $entities AS row
328
+ WITH row
329
+ ORDER BY 'entity:' + row.uuid
328
330
  MERGE (entity:Entity {uuid: row.uuid})
329
331
  ON CREATE SET entity.group_id = $group_id,
330
332
  entity.name = row.name,
@@ -365,6 +367,8 @@ class StoreKnowledge:
365
367
  entity.fuli_preference_project_id = row.preference_project_id,
366
368
  entity.fuli_preference_agent_id = row.preference_agent_id,
367
369
  entity.fuli_attributes_json = row.attributes_json
370
+ SET entity.fuli_ai_review_lock_version =
371
+ coalesce(entity.fuli_ai_review_lock_version, 0) + 1
368
372
  WITH count(entity) AS entity_count
369
373
  CREATE (episode:Episodic {
370
374
  uuid: $episode_id,
@@ -392,12 +396,95 @@ class StoreKnowledge:
392
396
  })
393
397
  WITH episode
394
398
  UNWIND $entities AS row
399
+ WITH episode, row
400
+ ORDER BY 'entity:' + row.uuid
395
401
  MATCH (entity:Entity {uuid: row.uuid})
396
402
  MERGE (episode)-[mention:MENTIONS {uuid: row.mention_uuid}]->(entity)
397
403
  SET mention.group_id = $group_id,
398
404
  mention.created_at = $created_at
399
405
  WITH episode
406
+ CALL {
407
+ WITH episode
408
+ OPTIONAL MATCH ()-[old:RELATES_TO {group_id: $group_id}]->()
409
+ WITH collect(old) AS candidate_olds
410
+ UNWIND candidate_olds AS old
411
+ WITH old
412
+ WHERE old IS NOT NULL
413
+ AND old.invalid_at IS NULL
414
+ AND NOT (old.uuid IN $relationship_ids)
415
+ AND (
416
+ EXISTS {
417
+ MATCH (:FuliSpace {id: $space_id})-
418
+ [:HAS_KNOWLEDGE_ASSIGNMENT]->
419
+ (assignment:FuliKnowledgeAssignment {
420
+ item_kind: 'relationship',
421
+ item_id: old.uuid
422
+ })
423
+ WHERE assignment.project_id = $personal_project_id
424
+ }
425
+ OR (
426
+ NOT EXISTS {
427
+ MATCH (:FuliSpace {id: $space_id})-
428
+ [:HAS_KNOWLEDGE_ASSIGNMENT]->
429
+ (:FuliKnowledgeAssignment {
430
+ item_kind: 'relationship',
431
+ item_id: old.uuid
432
+ })
433
+ }
434
+ AND EXISTS {
435
+ MATCH (old_episode:Episodic {group_id: $group_id})
436
+ WHERE old_episode.uuid IN coalesce(old.episodes, [])
437
+ AND (
438
+ old_episode.fuli_personal_project_id =
439
+ $personal_project_id
440
+ OR (
441
+ old_episode.fuli_personal_project_id IS NULL
442
+ AND $personal_project_id IS NULL
443
+ )
444
+ )
445
+ }
446
+ )
447
+ )
448
+ AND EXISTS {
449
+ MATCH (agent_episode:Episodic {group_id: $group_id})
450
+ WHERE agent_episode.uuid IN coalesce(old.episodes, [])
451
+ AND (
452
+ agent_episode.fuli_project_agent_id = $project_agent_id
453
+ OR (
454
+ agent_episode.fuli_project_agent_id IS NULL
455
+ AND $project_agent_id IS NULL
456
+ )
457
+ )
458
+ }
459
+ AND any(
460
+ replacement IN $superseded_relationships
461
+ WHERE replacement.key = old.fuli_key
462
+ )
463
+ WITH collect(old) AS superseded_edges
464
+ OPTIONAL MATCH ()-[existing:RELATES_TO {
465
+ group_id: $group_id
466
+ }]->()
467
+ WITH superseded_edges, collect(existing) AS candidate_existing
468
+ RETURN superseded_edges,
469
+ [edge IN candidate_existing
470
+ WHERE edge IS NOT NULL
471
+ AND edge.uuid IN $relationship_ids]
472
+ AS existing_edges
473
+ }
474
+ CALL {
475
+ WITH superseded_edges, existing_edges
476
+ UNWIND superseded_edges + existing_edges AS lock_edge
477
+ WITH lock_edge
478
+ WHERE lock_edge IS NOT NULL
479
+ ORDER BY 'relationship:' + lock_edge.uuid
480
+ SET lock_edge.fuli_ai_review_lock_version =
481
+ coalesce(lock_edge.fuli_ai_review_lock_version, 0) + 1
482
+ RETURN count(*) AS _locked_relationship_count
483
+ }
484
+ WITH episode, superseded_edges
400
485
  UNWIND $relationships AS row
486
+ WITH episode, superseded_edges, row
487
+ ORDER BY 'relationship:' + row.uuid
401
488
  MATCH (source:Entity {uuid: row.source_uuid})
402
489
  MATCH (target:Entity {uuid: row.target_uuid})
403
490
  MERGE (source)-[edge:RELATES_TO {uuid: row.uuid}]->(target)
@@ -435,73 +522,26 @@ class StoreKnowledge:
435
522
  edge.fuli_workflow_confirmation_authority =
436
523
  row.workflow_confirmation_authority,
437
524
  edge.fuli_workflow_session_authority =
438
- row.workflow_session_authority
525
+ row.workflow_session_authority,
526
+ edge.fuli_ai_review_lock_version = 1
439
527
  SET edge.episodes =
440
528
  CASE WHEN $episode_id IN coalesce(edge.episodes, [])
441
529
  THEN edge.episodes
442
530
  ELSE coalesce(edge.episodes, []) + $episode_id END
443
- WITH count(edge) AS edge_count
444
- OPTIONAL MATCH ()-[old:RELATES_TO {group_id: $group_id}]->()
445
- WHERE old.invalid_at IS NULL
446
- AND NOT (old.uuid IN $relationship_ids)
447
- AND (
448
- EXISTS {
449
- MATCH (:FuliSpace {id: $space_id})-
450
- [:HAS_KNOWLEDGE_ASSIGNMENT]->
451
- (assignment:FuliKnowledgeAssignment {
452
- item_kind: 'relationship',
453
- item_id: old.uuid
454
- })
455
- WHERE assignment.project_id = $personal_project_id
456
- }
457
- OR (
458
- NOT EXISTS {
459
- MATCH (:FuliSpace {id: $space_id})-
460
- [:HAS_KNOWLEDGE_ASSIGNMENT]->
461
- (:FuliKnowledgeAssignment {
462
- item_kind: 'relationship',
463
- item_id: old.uuid
464
- })
465
- }
466
- AND EXISTS {
467
- MATCH (old_episode:Episodic {group_id: $group_id})
468
- WHERE old_episode.uuid IN coalesce(old.episodes, [])
469
- AND (
470
- old_episode.fuli_personal_project_id =
471
- $personal_project_id
472
- OR (
473
- old_episode.fuli_personal_project_id IS NULL
474
- AND $personal_project_id IS NULL
475
- )
476
- )
477
- }
478
- )
479
- )
480
- AND EXISTS {
481
- MATCH (agent_episode:Episodic {group_id: $group_id})
482
- WHERE agent_episode.uuid IN coalesce(old.episodes, [])
483
- AND (
484
- agent_episode.fuli_project_agent_id = $project_agent_id
485
- OR (
486
- agent_episode.fuli_project_agent_id IS NULL
487
- AND $project_agent_id IS NULL
488
- )
489
- )
490
- }
491
- AND any(
531
+ WITH episode, superseded_edges, count(edge) AS edge_count
532
+ CALL {
533
+ WITH superseded_edges
534
+ UNWIND superseded_edges AS old
535
+ WITH old, head([
492
536
  replacement IN $superseded_relationships
493
- WHERE replacement.key = old.fuli_key
494
- )
495
- WITH edge_count, old, head([
496
- replacement IN $superseded_relationships
497
- WHERE replacement.key = old.fuli_key | replacement
498
- ]) AS replacement
499
- FOREACH (_ IN CASE WHEN old IS NULL THEN [] ELSE [1] END |
537
+ WHERE replacement.key = old.fuli_key | replacement
538
+ ]) AS replacement
500
539
  SET old.invalid_at = $reference_time,
501
540
  old.expired_at = $created_at,
502
541
  old.fuli_replaced_by_item_id = replacement.replacement_id,
503
542
  old.fuli_replaced_by_item_kind = 'relationship'
504
- )
543
+ RETURN count(*) AS _superseded_relationship_count
544
+ }
505
545
  RETURN edge_count
506
546
  ''',
507
547
  entities=[
@@ -24,7 +24,7 @@ class StoreProjectAgentAttention:
24
24
  self, actor, space, request.personal_project_id, agent_id,
25
25
  require_active=True,
26
26
  allow_unassigned=agent_id in {
27
- 'employee.bole', 'employee.jefa', 'fuli-project-coordinator',
27
+ 'employee.bole', 'employee.jefa', 'employee.tonborg', 'fuli-project-coordinator',
28
28
  },
29
29
  )
30
30
  if request.task_id:
@@ -4,16 +4,21 @@ from .personal_project_access import authorize_personal_project
4
4
  from .project_agent_context_models import ProjectAgentContextResolution
5
5
  from .project_agent_task_models import ProjectAgentTaskSubmit
6
6
  from .task_context_temporary_owner import temporary_task_owner
7
+ from .project_agent_delegations import StoreAgentDelegations
7
8
 
8
9
 
9
- class StoreProjectAgentContext:
10
+ class StoreProjectAgentContext(StoreAgentDelegations):
10
11
  async def resolve_project_agent_context(self, actor, request):
11
12
  self._require_personal()
12
13
  space = await self.authorize(actor, request.personal_space_id, 'reader')
13
14
  await authorize_personal_project(self, actor, space, request.personal_project_id)
14
- temporary_owner = await temporary_task_owner(self, actor, request)
15
+ temporary_owner = await temporary_task_owner(self, actor, request) if not request.delegation_token else None
15
16
  if temporary_owner is not None:
16
17
  return temporary_owner
18
+ policy = await self.get_project_agent_coordination_policy(
19
+ actor, request.personal_space_id, request.personal_project_id,
20
+ )
21
+ project_lead = getattr(policy, 'team_lead_agent_id', None)
17
22
  owner = None
18
23
  if request.session_id and not request.agent_id:
19
24
  rows, _, _ = await self.runtime.driver.execute_query(
@@ -39,7 +44,8 @@ class StoreProjectAgentContext:
39
44
  idempotency_key='read-only-task-context',
40
45
  title='Resolve task context', objective='Select one existing durable role.',
41
46
  work_kind=request.work_kind, required_capabilities=request.required_capabilities,
42
- lead_agent_id=request.agent_id or owner, source_application=request.source_application,
47
+ lead_agent_id=request.agent_id or (project_lead if owner else None),
48
+ source_application=request.source_application,
43
49
  routing_reason='Read-only task-entry context recovery',
44
50
  )
45
51
  if owner and not request.agent_id:
@@ -71,16 +77,58 @@ class StoreProjectAgentContext:
71
77
  'using project_context for continuity only',
72
78
  *basis,
73
79
  ]
74
- if owner and selected:
80
+ requested_agent_id = None
81
+ reporting_lead_agent_id = None
82
+ peer_request = bool(request.agent_id and selected and self._is_implicit_peer(selected[0]))
83
+ delegated = bool(request.delegation_token)
84
+ if delegated and (not selected or selected[0]['agent_id'] != request.agent_id):
85
+ return ProjectAgentContextResolution(status='agent_unavailable', reason='delegation_invalid')
86
+ if not peer_request:
87
+ if not project_lead:
88
+ if delegated:
89
+ return ProjectAgentContextResolution(status='agent_unavailable', reason='delegation_invalid')
90
+ return ProjectAgentContextResolution(
91
+ status='unassigned', reason='project_lead_required',
92
+ requested_agent_id=request.agent_id,
93
+ match_basis=['Every project requires one accountable lead before work begins.'],
94
+ )
95
+ if selected and selected[0]['agent_id'] != project_lead:
96
+ # Validate the named specialist first, then route accountability
97
+ # through the lead. Never load the specialist's private memory here.
98
+ rows = await self._assignment_candidates(request.personal_space_id, request.personal_project_id)
99
+ lead = next((row for row in rows if row['agent_id'] == project_lead
100
+ and (delegated or self._agent_client_allowed(row, request.source_application))), None)
101
+ if not lead:
102
+ return ProjectAgentContextResolution(status='agent_unavailable',
103
+ reason='team_lead_unavailable', match_basis=['The configured project lead is unavailable.'])
104
+ if delegated:
105
+ # A real lead-to-member worker invocation keeps the member's
106
+ # isolated identity; its result returns to the project lead.
107
+ reporting_lead_agent_id = project_lead
108
+ reason = 'delegated_member'
109
+ basis = ['This member worker reports to the configured project lead.']
110
+ else:
111
+ requested_agent_id = request.agent_id
112
+ selected = [lead]
113
+ reason = 'project_lead_reporting'
114
+ basis = ['The project lead owns the task; requested members report results to this lead.']
115
+ if owner and selected and owner == selected[0]['agent_id']:
75
116
  reason, basis = 'active_task_owner', ['reuse the owner of this host session task']
76
117
  agent = await self.get_project_agent(
77
118
  actor, request.personal_space_id, request.personal_project_id,
78
119
  selected[0]['agent_id'],
79
120
  ) if selected else None
121
+ # A reporting lead does not execute in the member's client. Acknowledge
122
+ # the capability only after the executing role passes selection, so a
123
+ # failed resolution cannot be accepted as that member's answer.
124
+ if delegated and (not agent or not await self.redeem_agent_delegation(request, project_lead)):
125
+ return ProjectAgentContextResolution(status='agent_unavailable', reason='delegation_invalid')
80
126
  return ProjectAgentContextResolution(
81
127
  status='ready' if agent else (
82
128
  'manual_selection' if reason == 'manual_agent_selection'
83
129
  else 'agent_unavailable' if reason == 'agent_unavailable' else 'unassigned'
84
130
  ),
85
131
  agent=agent, reason=reason, match_basis=basis, candidate_count=len(candidates),
132
+ requested_agent_id=requested_agent_id,
133
+ reporting_lead_agent_id=reporting_lead_agent_id,
86
134
  )
@@ -93,6 +93,8 @@ class StoreProjectAgentCoordinationPolicy:
93
93
  members = (request.team_member_agent_ids if 'team_member_agent_ids' in request.model_fields_set
94
94
  else current.team_member_agent_ids)
95
95
  auto_grow = current.auto_grow_team if request.auto_grow_team is None else request.auto_grow_team
96
+ if 'team_lead_agent_id' in request.model_fields_set and not lead:
97
+ raise HTTPException(status_code=422, detail='each project requires a lead; select a replacement instead of clearing the lead')
96
98
  if members and not lead:
97
99
  raise HTTPException(status_code=422, detail='select a team lead before adding members')
98
100
  if lead in members:
@@ -115,9 +117,21 @@ class StoreProjectAgentCoordinationPolicy:
115
117
  updated_at = now_utc()
116
118
  written, _, _ = await self.runtime.driver.execute_query(
117
119
  '''
118
- MATCH (:FuliSpace {id: $personal_space_id, kind: 'personal'})-
120
+ MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})-
119
121
  [:CONTAINS_PROJECT]->
120
122
  (project:FuliPersonalProject {project_id: $personal_project_id})
123
+ OPTIONAL MATCH (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(role:FuliProjectAgent)
124
+ WHERE role.agent_id IN $team_agent_ids
125
+ WITH project, role ORDER BY role.agent_id
126
+ FOREACH (locked IN CASE WHEN role IS NULL THEN [] ELSE [role] END |
127
+ SET locked._task_lifecycle_lock = true
128
+ REMOVE locked._task_lifecycle_lock)
129
+ WITH project, collect(role) AS roles
130
+ WHERE size(roles) = size($team_agent_ids)
131
+ AND all(role IN roles WHERE role.status = 'active' AND EXISTS {
132
+ MATCH (project)-[:HAS_PROJECT_AGENT_ASSIGNMENT]->
133
+ (:FuliProjectAgentAssignment {status: 'active'})-[:ASSIGNED_AGENT]->(role)
134
+ })
121
135
  MERGE (policy:FuliProjectAgentCoordinationPolicy {
122
136
  policy_id: $policy_id
123
137
  })
@@ -147,6 +161,7 @@ class StoreProjectAgentCoordinationPolicy:
147
161
  auto_grow_team=auto_grow,
148
162
  team_lead_agent_id=lead,
149
163
  team_member_agent_ids=members,
164
+ team_agent_ids=([lead] if lead else []) + members,
150
165
  expected_updated_at=current.updated_at,
151
166
  updated_at=updated_at,
152
167
  )
@@ -41,6 +41,19 @@ class StoreProjectAgentTaskActivity:
41
41
  space,
42
42
  request.personal_project_id,
43
43
  )
44
+ if request.judgment_task_context_token:
45
+ # Match the task-context lifecycle lock order: session, then task.
46
+ # A new host turn cannot supersede the checked token before commit.
47
+ contexts, _, _ = await self.runtime.driver.execute_query(
48
+ '''
49
+ MATCH (:FuliSpace {id: $space_id, kind: 'personal'})-
50
+ [:HAS_TASK_CONTEXT_SESSION]->(session:FuliTaskContextSession)-
51
+ [:HAS_CONTEXT]->(context:FuliTaskContext {token: $token})
52
+ SET session.write_serial = coalesce(session.write_serial, 0) + 1
53
+ RETURN context.token AS token
54
+ ''', space_id=request.personal_space_id, token=request.judgment_task_context_token)
55
+ if not contexts:
56
+ raise HTTPException(403, 'Judgment acceptance context is unavailable')
44
57
  # Acquire the task's write lock before reading its revision, transition
45
58
  # state or idempotency events. A transaction alone permits two concurrent
46
59
  # readers to validate the same revision before either obtains a lock.
@@ -96,6 +109,19 @@ class StoreProjectAgentTaskActivity:
96
109
  if (not request.artifact_revision or raw_task.get('quality_gate') != 'passed'
97
110
  or raw_task.get('verified_artifact_revision') != request.artifact_revision):
98
111
  raise HTTPException(409, 'Verify this exact artifact revision before completing the task')
112
+ if request.expected_verification_attempt_id is not None:
113
+ if (request.status != 'completed' or raw_task.get('quality_gate') != 'passed'
114
+ or raw_task.get('verified_attempt_id') != request.expected_verification_attempt_id
115
+ or raw_task.get('verified_artifact_revision') != request.artifact_revision):
116
+ raise HTTPException(409, 'Verification evidence changed before task acceptance')
117
+ if not request.judgment_task_context_token or not request.source_application:
118
+ raise HTTPException(403, 'Judgment acceptance requires a current lead context')
119
+ context = await self.get_task_context(actor, request.personal_space_id,
120
+ request.judgment_task_context_token, request.source_application)
121
+ if (context.get('personal_project_id') != request.personal_project_id
122
+ or context.get('project_agent_id') != raw_task.get('lead_agent_id')
123
+ or request.agent_id != raw_task.get('lead_agent_id')):
124
+ raise HTTPException(403, 'Judgment acceptance requires the current accountable lead')
99
125
  self._validate_task_transition(
100
126
  raw_task.get('status'),
101
127
  request.status,
@@ -13,10 +13,11 @@ from .system_hr_identity import LEGACY_HR_AGENT_ID
13
13
 
14
14
  IMPLICIT_PEER_AGENT_IDS = frozenset({
15
15
  'employee.jefa',
16
+ 'employee.tonborg',
16
17
  SYSTEM_HR_AGENT_ID.casefold(),
17
18
  LEGACY_HR_AGENT_ID.casefold(),
18
19
  })
19
- IMPLICIT_PEER_CAPABILITIES = frozenset({'fuli.employee:jefa', 'fuli.employee:bole'})
20
+ IMPLICIT_PEER_CAPABILITIES = frozenset({'fuli.employee:jefa', 'fuli.employee:bole', 'fuli.employee:tonborg'})
20
21
  IMPLICIT_PEER_AGENT_TYPES = frozenset({'hr'})
21
22
 
22
23
 
@@ -54,14 +55,15 @@ class StoreProjectAgentTaskStaffing:
54
55
  row for row in assignment_rows
55
56
  if self._agent_client_allowed(row, request.source_application)
56
57
  ]
57
- if request.staffing_intent in {'new_durable', 'temporary'}:
58
+ if request.staffing_intent in {'new_durable', 'temporary', 'default_lead'}:
58
59
  return (
59
60
  [],
60
61
  rows,
61
62
  'explicit_temporary_agent'
62
63
  if request.staffing_intent == 'temporary'
63
64
  else 'explicit_new_agent',
64
- ['user explicitly requested recruitment'],
65
+ ['project requires an accountable lead' if request.staffing_intent == 'default_lead'
66
+ else 'user explicitly requested recruitment'],
65
67
  )
66
68
  policy = await self.get_project_agent_coordination_policy(
67
69
  actor,
@@ -5,6 +5,9 @@ from fastapi import HTTPException
5
5
 
6
6
  from .personal_project_access import authorize_personal_project
7
7
  from .project_agent_access import authorize_project_agent
8
+ from .project_agent_employee_numbers import (
9
+ ASSIGN_EMPLOYEE_NUMBERS, EMPLOYEE_NUMBER_LOCK, employee_number_label,
10
+ )
8
11
  from .project_agent_models import (
9
12
  ProjectAgentAssignmentCreate,
10
13
  ProjectAgentAssignmentEnd,
@@ -134,6 +137,7 @@ class StoreProjectAgents:
134
137
  records, _, _ = await self.runtime.driver.execute_query(
135
138
  '''
136
139
  MATCH (space:FuliSpace {id: $personal_space_id, kind: 'personal'})
140
+ ''' + EMPLOYEE_NUMBER_LOCK + '''
137
141
  MERGE (agent:FuliProjectAgent {id: $id})
138
142
  ON CREATE SET agent.agent_id = $agent_id,
139
143
  agent.created_at = $updated_at,
@@ -143,7 +147,12 @@ class StoreProjectAgents:
143
147
  SET agent._profile_upsert_lock = true
144
148
  REMOVE agent._profile_upsert_lock
145
149
  WITH space, agent
146
- WHERE agent.status IS NULL OR agent.status <> 'archived'
150
+ WHERE (agent.status IS NULL OR agent.status <> 'archived')
151
+ AND ($status = 'active' OR NOT EXISTS {
152
+ MATCH (space)-[:CONTAINS_PROJECT]->(:FuliPersonalProject)-
153
+ [:HAS_PROJECT_AGENT_COORDINATION_POLICY]->(policy:FuliProjectAgentCoordinationPolicy)
154
+ WHERE policy.team_lead_agent_id = agent.agent_id
155
+ })
147
156
  SET agent.profile_json = $profile_json,
148
157
  agent.name = $name,
149
158
  agent.occupation_emoji = $occupation_emoji,
@@ -159,6 +168,7 @@ class StoreProjectAgents:
159
168
  agent.recruitment_id, $recruitment_id),
160
169
  agent.updated_at = $updated_at
161
170
  MERGE (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(agent)
171
+ ''' + ASSIGN_EMPLOYEE_NUMBERS + '''
162
172
  RETURN agent
163
173
  ''',
164
174
  personal_space_id=request.personal_space_id,
@@ -182,7 +192,7 @@ class StoreProjectAgents:
182
192
  raise HTTPException(
183
193
  status_code=409,
184
194
  detail=(
185
- 'archived Project Agents require a dedicated restore operation'
195
+ 'archived Agents require a dedicated restore operation; project leads must hand off before deactivation'
186
196
  ),
187
197
  )
188
198
  if request.personal_project_id:
@@ -420,7 +430,12 @@ class StoreProjectAgents:
420
430
  'awaiting_recruitment', 'queued', 'running',
421
431
  'paused', 'blocked', 'awaiting_review'
422
432
  ]] AS open_tasks
423
- WHERE agent.status = 'archived' OR size(open_tasks) = 0
433
+ WHERE (agent.status = 'archived' OR size(open_tasks) = 0)
434
+ AND NOT EXISTS {
435
+ MATCH (space)-[:CONTAINS_PROJECT]->(:FuliPersonalProject)-
436
+ [:HAS_PROJECT_AGENT_COORDINATION_POLICY]->(policy:FuliProjectAgentCoordinationPolicy)
437
+ WHERE policy.team_lead_agent_id = agent.agent_id
438
+ }
424
439
  SET agent.status = 'archived',
425
440
  agent.archive_reason = coalesce(agent.archive_reason, $reason),
426
441
  agent.archived_at = coalesce(agent.archived_at, $updated_at),
@@ -446,7 +461,7 @@ class StoreProjectAgents:
446
461
  if not records:
447
462
  raise HTTPException(
448
463
  status_code=409,
449
- detail='Agent is missing or still has non-terminal task work',
464
+ detail='Agent is missing, has non-terminal task work, or is a project lead; hand off before archiving',
450
465
  )
451
466
  return await self.get_project_agent(
452
467
  actor,
@@ -768,6 +783,11 @@ class StoreProjectAgents:
768
783
  WITH project, assignment, agent
769
784
  WHERE assignment.status = 'active'
770
785
  AND coalesce(assignment.revision, 0) = $expected_revision
786
+ AND NOT EXISTS {
787
+ MATCH (project)-[:HAS_PROJECT_AGENT_COORDINATION_POLICY]->
788
+ (policy:FuliProjectAgentCoordinationPolicy)
789
+ WHERE policy.team_lead_agent_id = agent.agent_id
790
+ }
771
791
  SET assignment.status = 'ended',
772
792
  assignment.end_reason = $reason,
773
793
  assignment.ended_at = $updated_at,
@@ -786,7 +806,7 @@ class StoreProjectAgents:
786
806
  if not records:
787
807
  raise HTTPException(
788
808
  status_code=409,
789
- detail='assignment is not active or revision is stale',
809
+ detail='assignment is not active, revision is stale, or project leadership requires a handoff',
790
810
  )
791
811
  row = records[0]
792
812
  return self._assignment(
@@ -851,6 +871,11 @@ class StoreProjectAgents:
851
871
  REMOVE lifecycle_agent._task_lifecycle_lock
852
872
  WITH project, ended, ended_agent, replacement_agent,
853
873
  collect(lifecycle_agent) AS locked_agents
874
+ WHERE ended.status <> 'active' OR NOT EXISTS {
875
+ MATCH (project)-[:HAS_PROJECT_AGENT_COORDINATION_POLICY]->
876
+ (policy:FuliProjectAgentCoordinationPolicy)
877
+ WHERE policy.team_lead_agent_id = ended_agent.agent_id
878
+ }
854
879
  OPTIONAL MATCH (existing_replacement:FuliProjectAgentAssignment {
855
880
  id: $replacement_id
856
881
  })
@@ -1075,6 +1100,7 @@ class StoreProjectAgents:
1075
1100
  })
1076
1101
  return ProjectAgentRecord(
1077
1102
  agent_id=raw['agent_id'],
1103
+ employee_number=employee_number_label(raw.get('employee_number')),
1078
1104
  legacy_agent_ids=list(raw.get('legacy_agent_ids') or []),
1079
1105
  personal_space_id=personal_space_id,
1080
1106
  personal_project_id=projection_project_id,
@@ -1,4 +1,4 @@
1
- """The two fixed roles every personal space has: Jefa (project manager) and Bole (HR).
1
+ """Fixed roles: Jefa (project manager), Bole (HR), and Tonborg (judgment).
2
2
 
3
3
  Jefa also coordinates task routing, replacing the earlier separate coordinator
4
4
  identity. That legacy node is retired, not deleted, so old tasks keep their
@@ -8,11 +8,13 @@ original coordinator reference.
8
8
  import json
9
9
 
10
10
  from .project_agent_models import ProjectAgentProfile
11
+ from .project_agent_employee_numbers import ASSIGN_EMPLOYEE_NUMBERS, EMPLOYEE_NUMBER_LOCK
11
12
  from .provider_values import now_utc, stable_uuid
12
13
 
13
14
  SYSTEM_COORDINATOR_AGENT_ID = 'employee.jefa'
14
15
  LEGACY_COORDINATOR_AGENT_ID = 'fuli-project-coordinator'
15
16
  SYSTEM_HR_AGENT_ID = 'employee.bole'
17
+ SYSTEM_JUDGMENT_AGENT_ID = 'employee.tonborg'
16
18
 
17
19
 
18
20
  def coordinator_profile() -> ProjectAgentProfile:
@@ -47,9 +49,27 @@ def hr_profile() -> ProjectAgentProfile:
47
49
  )
48
50
 
49
51
 
52
+ def judgment_profile() -> ProjectAgentProfile:
53
+ return ProjectAgentProfile(
54
+ name='Tonborg',
55
+ occupation_emoji='⚖️',
56
+ responsibility='判断官:依照用户偏好审核日常事项、建议客户端和模型,保留判断依据与反馈。',
57
+ agent_type='durable',
58
+ work_kinds=['judgment', 'knowledge-review', 'executor-selection'],
59
+ capabilities=['判断审核', '执行器选择', '判断反馈', 'fuli.employee:tonborg'],
60
+ initial_preferences=[
61
+ '用户明确偏好优先;只把相关范围的历史反馈用于当前判断。',
62
+ '不把模型建议当作已执行,不把 AI 审核冒充用户确认。',
63
+ '保持项目总负责人与汇报关系。',
64
+ ],
65
+ status='active',
66
+ )
67
+
68
+
50
69
  SYSTEM_PROFILES = {
51
70
  SYSTEM_COORDINATOR_AGENT_ID: coordinator_profile,
52
71
  SYSTEM_HR_AGENT_ID: hr_profile,
72
+ SYSTEM_JUDGMENT_AGENT_ID: judgment_profile,
53
73
  }
54
74
 
55
75
 
@@ -75,6 +95,7 @@ async def ensure_system_identity(driver, provider_id, space_id, agent_id):
75
95
  await driver.execute_query(
76
96
  '''
77
97
  MATCH (space:FuliSpace {id: $space_id, kind: 'personal'})
98
+ ''' + EMPLOYEE_NUMBER_LOCK + '''
78
99
  MERGE (agent:FuliProjectAgent {id: $id})
79
100
  ON CREATE SET agent.agent_id = $agent_id,
80
101
  agent.name = $name,
@@ -90,7 +111,7 @@ async def ensure_system_identity(driver, provider_id, space_id, agent_id):
90
111
  agent.status = 'active',
91
112
  agent.system_managed = true
92
113
  MERGE (space)-[:HAS_PROJECT_AGENT_IDENTITY]->(agent)
93
- ''',
114
+ ''' + ASSIGN_EMPLOYEE_NUMBERS + ' RETURN agent',
94
115
  space_id=space_id,
95
116
  id=node_id,
96
117
  agent_id=agent_id,